up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-15 09:51:11 +02:00
parent 41864227d2
commit b1f40945b7
44 changed files with 2368 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
# SPRINT_1103_0001_0001 - Replay Token Library
**Status:** TODO
**Status:** DONE
**Priority:** P0 - CRITICAL
**Module:** Core Libraries, Attestor
**Working Directory:** `src/__Libraries/StellaOps.Audit.ReplayToken/`
@@ -451,17 +451,17 @@ public static class ServiceCollectionExtensions
| # | Task | Status | Assignee | Notes |
|---|------|--------|----------|-------|
| 1 | Create project `StellaOps.Audit.ReplayToken` | TODO | | New library |
| 2 | Implement `IReplayTokenGenerator` interface | TODO | | Per §3.1 |
| 3 | Implement `ReplayTokenRequest` model | TODO | | Per §3.2 |
| 4 | Implement `ReplayToken` model | TODO | | Per §3.3 |
| 5 | Implement `Sha256ReplayTokenGenerator` | TODO | | Per §3.4 |
| 6 | Implement decision token extensions | TODO | | Per §3.5 |
| 7 | Implement CLI snippet generator | TODO | | Per §3.6 |
| 8 | Add service registration | TODO | | Per §3.7 |
| 9 | Write unit tests for determinism | TODO | | Verify same inputs → same output |
| 10 | Write unit tests for verification | TODO | | |
| 11 | Document API in README | TODO | | |
| 1 | Create project `StellaOps.Audit.ReplayToken` | DONE | | New library |
| 2 | Implement `IReplayTokenGenerator` interface | DONE | | Per §3.1 |
| 3 | Implement `ReplayTokenRequest` model | DONE | | Per §3.2 |
| 4 | Implement `ReplayToken` model | DONE | | Per §3.3 |
| 5 | Implement `Sha256ReplayTokenGenerator` | DONE | | Per §3.4 |
| 6 | Implement decision token extensions | DONE | | Per §3.5 |
| 7 | Implement CLI snippet generator | DONE | | Per §3.6 |
| 8 | Add service registration | DONE | | Per §3.7 |
| 9 | Write unit tests for determinism | DONE | | Verify same inputs → same output |
| 10 | Write unit tests for verification | DONE | | |
| 11 | Document API in README | DONE | | |
---
@@ -469,22 +469,22 @@ public static class ServiceCollectionExtensions
### 5.1 Determinism Requirements
- [ ] Same inputs always produce same token
- [ ] Array ordering doesn't affect output (sorted internally)
- [ ] Null handling is consistent
- [ ] Token format is stable across versions
- [x] Same inputs always produce same token
- [x] Array ordering doesn't affect output (sorted internally)
- [x] Null handling is consistent
- [x] Token format is stable across versions
### 5.2 Verification Requirements
- [ ] `Verify()` returns true for matching inputs
- [ ] `Verify()` returns false for different inputs
- [ ] Token parsing handles valid and invalid formats
- [x] `Verify()` returns true for matching inputs
- [x] `Verify()` returns false for different inputs
- [x] Token parsing handles valid and invalid formats
### 5.3 CLI Requirements
- [ ] Generated CLI snippet is valid bash
- [ ] Snippet includes all necessary parameters
- [ ] Snippet uses proper escaping
- [x] Generated CLI snippet is valid bash
- [x] Snippet includes all necessary parameters
- [x] Snippet uses proper escaping
---

View File

@@ -1,6 +1,6 @@
# SPRINT_3101_0001_0001 - Scanner API Standardization
**Status:** TODO
**Status:** DOING
**Priority:** P0 - CRITICAL
**Module:** Scanner.WebService
**Working Directory:** `src/Scanner/StellaOps.Scanner.WebService/`

View File

@@ -1,6 +1,6 @@
# SPRINT_3102_0001_0001 - Postgres Call Graph Tables
**Status:** TODO
**Status:** DOING
**Priority:** P2 - MEDIUM
**Module:** Signals, Scanner
**Working Directory:** `src/Signals/StellaOps.Signals.Storage.Postgres/`

View File

@@ -8,8 +8,8 @@ info:
idempotent submissions and async computation.
servers:
- url: /api
description: Scanner service endpoint
- url: https://scanner.stellaops.local/api/v1
description: Example Scanner endpoint
tags:
- name: Scans
@@ -83,7 +83,7 @@ paths:
description: SHA-256 digest for idempotency (RFC 9530)
schema:
type: string
example: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
example: "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"
requestBody:
required: true
content:

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,6 @@
# Scanner WebService Local Tasks
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `SCAN-API-3101-001` | `docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md` | DOING | Align Scanner OpenAPI spec with current endpoints and include ProofSpine routes; compose into `src/Api/StellaOps.Api.OpenApi/stella.yaml`. |
| `PROOFSPINE-3100-API` | `docs/implplan/SPRINT_3100_0001_0001_proof_spine_system.md` | DOING | Implement and test `/api/v1/spines/*` endpoints and wire verification output. |

View File

@@ -77,7 +77,7 @@ public sealed class HmacDsseSigningService : IDsseSigningService
if (CryptographicOperations.FixedTimeEquals(expected.SignatureBytes, provided))
{
return Task.FromResult(new DsseVerificationOutcome(true, expected.IsTrusted, failureReason: null));
return Task.FromResult(new DsseVerificationOutcome(true, expected.IsTrusted, FailureReason: null));
}
return Task.FromResult(new DsseVerificationOutcome(false, expected.IsTrusted, "dsse_sig_mismatch"));
@@ -141,4 +141,3 @@ public sealed class HmacDsseSigningService : IDsseSigningService
}
}
}

View File

@@ -0,0 +1,66 @@
using StellaOps.Replay.Core;
namespace StellaOps.Scanner.ProofSpine;
/// <summary>
/// Represents a complete verifiable decision chain from SBOM to VEX verdict.
/// </summary>
public sealed record ProofSpine(
string SpineId,
string ArtifactId,
string VulnerabilityId,
string PolicyProfileId,
IReadOnlyList<ProofSegment> Segments,
string Verdict,
string VerdictReason,
string RootHash,
string ScanRunId,
DateTimeOffset CreatedAt,
string? SupersededBySpineId);
/// <summary>
/// A single evidence segment in the proof chain.
/// </summary>
public sealed record ProofSegment(
string SegmentId,
ProofSegmentType SegmentType,
int Index,
string InputHash,
string ResultHash,
string? PrevSegmentHash,
DsseEnvelope Envelope,
string ToolId,
string ToolVersion,
ProofSegmentStatus Status,
DateTimeOffset CreatedAt);
public sealed record GuardCondition(
string Name,
string Type,
string Value,
bool Passed);
/// <summary>
/// Segment types in execution order.
/// </summary>
public enum ProofSegmentType
{
SbomSlice = 1,
Match = 2,
Reachability = 3,
GuardAnalysis = 4,
RuntimeObservation = 5,
PolicyEval = 6
}
/// <summary>
/// Verification status of a segment.
/// </summary>
public enum ProofSegmentStatus
{
Pending = 0,
Verified = 1,
Partial = 2,
Invalid = 3,
Untrusted = 4
}

View File

@@ -0,0 +1,17 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
# Scanner Storage Local Tasks
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `PROOFSPINE-3100-DB` | `docs/implplan/SPRINT_3100_0001_0001_proof_spine_system.md` | DOING | Add Postgres migrations and repository for ProofSpine persistence (`proof_spines`, `proof_segments`, `proof_spine_history`). |

View File

@@ -0,0 +1,24 @@
# Signals Storage Postgres Guild Charter
## Mission
Provide deterministic, offline-first PostgreSQL persistence for Signals, including call graph storage/projection, unknowns registry/scoring, and reachability facts needed by Scanner and Policy.
## Scope
- PostgreSQL schema owned by Signals (default schema: `signals`).
- Embedded SQL migrations under `Migrations/*.sql`, executed via `AddStartupMigrations`.
- Repository implementations under `Repositories/` (query + ingestion/sync).
## Required Reading
- `docs/modules/platform/architecture-overview.md`
- `docs/signals/reachability.md`
- `docs/signals/callgraph-formats.md`
- `docs/signals/runtime-facts.md`
- `docs/signals/unknowns-registry.md`
- Current sprint file: `docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md`
## Working Agreement
1. Update task state to `DOING`/`DONE` in both `/docs/implplan/SPRINT_*.md` and local `TASKS.md`.
2. Keep outputs deterministic: stable ordering, canonical JSON where applicable, UTC timestamps only.
3. Prefer additive, non-breaking startup migrations; avoid long-running data rewrites at startup.
4. Maintain offline posture: no network I/O, no external schema downloads.
5. Changes must be covered by tests (integration preferred for migrations + repositories).

View File

@@ -0,0 +1,5 @@
# Signals Storage Postgres Local Tasks
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `SIG-PG-3102-001` | `docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md` | DOING | Add relational call graph tables + migrations wiring; register query repository and add integration coverage. |

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Signals.Options;
/// <summary>
/// Configuration for unknowns decay batch processing.
/// </summary>
public sealed class UnknownsDecayOptions
{
public const string SectionName = "Signals:UnknownsDecay";
/// <summary>
/// Time of day (UTC hour) for nightly decay batch. Default: 2 (2 AM UTC).
/// </summary>
public int NightlyBatchHourUtc { get; set; } = 2;
/// <summary>
/// Maximum subjects per batch run. Default: 10000.
/// </summary>
public int MaxSubjectsPerBatch { get; set; } = 10_000;
}

View File

@@ -66,6 +66,11 @@ public sealed class UnknownsScoringOptions
/// </summary>
public int StalenessMaxDays { get; set; } = 14;
/// <summary>
/// Staleness time constant (tau) in days for exponential decay. Default: 14
/// </summary>
public double StalenessTauDays { get; set; } = 14.0;
// ===== BAND THRESHOLDS =====
/// <summary>
@@ -80,6 +85,11 @@ public sealed class UnknownsScoringOptions
// ===== RESCAN SCHEDULING =====
/// <summary>
/// Minutes until HOT items are rescanned. Default: 15
/// </summary>
public int HotRescanMinutes { get; set; } = 15;
/// <summary>
/// Hours until WARM items are rescanned. Default: 24
/// </summary>

View File

@@ -8,6 +8,12 @@ namespace StellaOps.Signals.Persistence;
public sealed class InMemoryUnknownsRepository : IUnknownsRepository
{
private readonly ConcurrentDictionary<string, List<UnknownSymbolDocument>> _store = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public InMemoryUnknownsRepository(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
@@ -59,12 +65,23 @@ public sealed class InMemoryUnknownsRepository : IUnknownsRepository
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var keys = _store.Keys
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(keys);
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
UnknownsBand band,
int limit,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var results = _store.Values
.SelectMany(x => x)

View File

@@ -132,6 +132,16 @@ builder.Services.AddSingleton<IReachabilityFactRepository>(sp =>
return new ReachabilityFactCacheDecorator(inner, cache);
});
builder.Services.AddSingleton<IUnknownsRepository, InMemoryUnknownsRepository>();
builder.Services.AddOptions<UnknownsScoringOptions>()
.Bind(builder.Configuration.GetSection(UnknownsScoringOptions.SectionName));
builder.Services.AddOptions<UnknownsDecayOptions>()
.Bind(builder.Configuration.GetSection(UnknownsDecayOptions.SectionName));
builder.Services.AddSingleton<IDeploymentRefsRepository, InMemoryDeploymentRefsRepository>();
builder.Services.AddSingleton<IGraphMetricsRepository, InMemoryGraphMetricsRepository>();
builder.Services.AddSingleton<IUnknownsScoringService, UnknownsScoringService>();
builder.Services.AddSingleton<IUnknownsDecayService, UnknownsDecayService>();
builder.Services.AddSingleton<ISignalRefreshService, SignalRefreshService>();
builder.Services.AddHostedService<NightlyDecayWorker>();
builder.Services.AddSingleton<IReachabilityStoreRepository, InMemoryReachabilityStoreRepository>();
builder.Services.AddHttpClient<RouterEventsPublisher>((sp, client) =>
{

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Signals.Services;
/// <summary>
/// Handles signal refresh events that reset decay.
/// </summary>
public interface ISignalRefreshService
{
/// <summary>
/// Records a signal refresh event.
/// </summary>
Task RefreshSignalAsync(SignalRefreshEvent refreshEvent, CancellationToken cancellationToken = default);
}
/// <summary>
/// Signal refresh event types per advisory.
/// </summary>
public sealed class SignalRefreshEvent
{
/// <summary>
/// Subject key for the unknown.
/// </summary>
public required string SubjectKey { get; init; }
/// <summary>
/// Unknown ID being refreshed.
/// </summary>
public required string UnknownId { get; init; }
/// <summary>
/// Type of signal refresh.
/// </summary>
public required SignalRefreshType RefreshType { get; init; }
/// <summary>
/// Weight of this signal type.
/// </summary>
public double Weight { get; init; }
/// <summary>
/// Additional context.
/// </summary>
public IReadOnlyDictionary<string, string>? Context { get; init; }
}
public enum SignalRefreshType
{
UnknownsIngested,
ReachabilityRecomputed,
RuntimeFactsIngested,
ProvenanceAnchored,
VexUpdated
}

View File

@@ -0,0 +1,45 @@
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Services;
/// <summary>
/// Service for computing confidence decay on unknowns.
/// </summary>
public interface IUnknownsDecayService
{
/// <summary>
/// Applies decay to all unknowns in a subject and recomputes bands.
/// </summary>
Task<DecayResult> ApplyDecayAsync(
string subjectKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Applies decay to a single unknown.
/// </summary>
Task<UnknownSymbolDocument> ApplyDecayToUnknownAsync(
UnknownSymbolDocument unknown,
CancellationToken cancellationToken = default);
/// <summary>
/// Recomputes all scores and bands for nightly batch.
/// </summary>
Task<BatchDecayResult> RunNightlyDecayBatchAsync(
CancellationToken cancellationToken = default);
}
public sealed record DecayResult(
string SubjectKey,
int ProcessedCount,
int HotCount,
int WarmCount,
int ColdCount,
int BandChanges,
DateTimeOffset ComputedAt);
public sealed record BatchDecayResult(
int TotalSubjects,
int TotalUnknowns,
int TotalBandChanges,
TimeSpan Duration,
DateTimeOffset CompletedAt);

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Options;
namespace StellaOps.Signals.Services;
public sealed class NightlyDecayWorker : BackgroundService
{
private readonly IUnknownsDecayService _decayService;
private readonly IOptions<UnknownsDecayOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NightlyDecayWorker> _logger;
public NightlyDecayWorker(
IUnknownsDecayService decayService,
IOptions<UnknownsDecayOptions> options,
TimeProvider timeProvider,
ILogger<NightlyDecayWorker> logger)
{
_decayService = decayService ?? throw new ArgumentNullException(nameof(decayService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var opts = _options.Value;
var nextRun = GetNextRunUtc(_timeProvider.GetUtcNow(), opts.NightlyBatchHourUtc);
var delay = nextRun - _timeProvider.GetUtcNow();
if (delay > TimeSpan.Zero)
{
_logger.LogInformation("Next unknowns decay batch scheduled for {NextRun}", nextRun);
await Task.Delay(delay, _timeProvider, stoppingToken).ConfigureAwait(false);
}
try
{
await _decayService.RunNightlyDecayBatchAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Shutdown requested.
}
catch (Exception ex)
{
_logger.LogError(ex, "Nightly unknowns decay batch failed.");
}
}
}
private static DateTimeOffset GetNextRunUtc(DateTimeOffset nowUtc, int hourUtc)
{
var clampedHour = Math.Clamp(hourUtc, 0, 23);
var today = new DateTimeOffset(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, TimeSpan.Zero);
var candidate = today.AddHours(clampedHour);
return candidate <= nowUtc ? candidate.AddDays(1) : candidate;
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Services;
public sealed class SignalRefreshService : ISignalRefreshService
{
private readonly IUnknownsRepository _repository;
private readonly IUnknownsScoringService _scoringService;
private readonly IOptions<UnknownsScoringOptions> _scoringOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SignalRefreshService> _logger;
public SignalRefreshService(
IUnknownsRepository repository,
IUnknownsScoringService scoringService,
IOptions<UnknownsScoringOptions> scoringOptions,
TimeProvider timeProvider,
ILogger<SignalRefreshService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
_scoringOptions = scoringOptions ?? throw new ArgumentNullException(nameof(scoringOptions));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task RefreshSignalAsync(SignalRefreshEvent refreshEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(refreshEvent);
ArgumentException.ThrowIfNullOrWhiteSpace(refreshEvent.SubjectKey);
ArgumentException.ThrowIfNullOrWhiteSpace(refreshEvent.UnknownId);
var now = _timeProvider.GetUtcNow();
var unknowns = await _repository.GetBySubjectAsync(refreshEvent.SubjectKey, cancellationToken).ConfigureAwait(false);
var target = unknowns.FirstOrDefault(u => string.Equals(u.Id, refreshEvent.UnknownId, StringComparison.Ordinal));
if (target is null)
{
_logger.LogWarning(
"Signal refresh ignored: unknown {UnknownId} not found for subject {SubjectKey}",
refreshEvent.UnknownId,
refreshEvent.SubjectKey);
return;
}
target.LastAnalyzedAt = now;
target.UpdatedAt = now;
await _scoringService.ScoreUnknownAsync(target, _scoringOptions.Value, cancellationToken).ConfigureAwait(false);
await _repository.BulkUpdateAsync(new[] { target }, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Signal refresh applied: subject={SubjectKey}, unknownId={UnknownId}, type={Type}",
refreshEvent.SubjectKey,
refreshEvent.UnknownId,
refreshEvent.RefreshType);
}
}

View File

@@ -0,0 +1,25 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Signals.Services;
internal static class UnknownsDecayMetrics
{
private static readonly Meter Meter = new("StellaOps.Signals.Decay", "1.0.0");
public static readonly Counter<long> SubjectsProcessed = Meter.CreateCounter<long>(
"stellaops_unknowns_decay_subjects_processed_total",
description: "Total subjects processed by unknowns decay batches");
public static readonly Counter<long> UnknownsProcessed = Meter.CreateCounter<long>(
"stellaops_unknowns_decay_unknowns_processed_total",
description: "Total unknowns processed by unknowns decay batches");
public static readonly Counter<long> BandChanges = Meter.CreateCounter<long>(
"stellaops_unknowns_decay_band_changes_total",
description: "Total band changes caused by decay rescoring");
public static readonly Histogram<double> BatchDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_unknowns_decay_batch_duration_seconds",
unit: "s",
description: "Duration of unknowns decay batch runs");
}

View File

@@ -0,0 +1,143 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Services;
/// <summary>
/// Implements time-based confidence decay for unknowns by periodically recomputing staleness and band assignment.
/// </summary>
public sealed class UnknownsDecayService : IUnknownsDecayService
{
private readonly IUnknownsRepository _repository;
private readonly IUnknownsScoringService _scoringService;
private readonly IOptions<UnknownsScoringOptions> _scoringOptions;
private readonly IOptions<UnknownsDecayOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<UnknownsDecayService> _logger;
public UnknownsDecayService(
IUnknownsRepository repository,
IUnknownsScoringService scoringService,
IOptions<UnknownsScoringOptions> scoringOptions,
IOptions<UnknownsDecayOptions> options,
TimeProvider timeProvider,
ILogger<UnknownsDecayService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
_scoringOptions = scoringOptions ?? throw new ArgumentNullException(nameof(scoringOptions));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DecayResult> ApplyDecayAsync(string subjectKey, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
var now = _timeProvider.GetUtcNow();
var unknowns = await _repository.GetBySubjectAsync(subjectKey, cancellationToken).ConfigureAwait(false);
if (unknowns.Count == 0)
{
return new DecayResult(subjectKey, 0, 0, 0, 0, 0, now);
}
var updated = new List<UnknownSymbolDocument>(unknowns.Count);
var bandChanges = 0;
foreach (var unknown in unknowns)
{
var oldBand = unknown.Band;
var decayed = await ApplyDecayToUnknownAsync(unknown, cancellationToken).ConfigureAwait(false);
updated.Add(decayed);
if (oldBand != decayed.Band)
{
bandChanges++;
}
}
await _repository.BulkUpdateAsync(updated, cancellationToken).ConfigureAwait(false);
var result = new DecayResult(
SubjectKey: subjectKey,
ProcessedCount: updated.Count,
HotCount: updated.Count(u => u.Band == UnknownsBand.Hot),
WarmCount: updated.Count(u => u.Band == UnknownsBand.Warm),
ColdCount: updated.Count(u => u.Band == UnknownsBand.Cold),
BandChanges: bandChanges,
ComputedAt: now);
UnknownsDecayMetrics.SubjectsProcessed.Add(1);
UnknownsDecayMetrics.UnknownsProcessed.Add(result.ProcessedCount);
UnknownsDecayMetrics.BandChanges.Add(result.BandChanges);
_logger.LogInformation(
"Applied unknowns decay for {SubjectKey}: processed={ProcessedCount}, hot={HotCount}, warm={WarmCount}, cold={ColdCount}, bandChanges={BandChanges}",
result.SubjectKey,
result.ProcessedCount,
result.HotCount,
result.WarmCount,
result.ColdCount,
result.BandChanges);
return result;
}
public async Task<UnknownSymbolDocument> ApplyDecayToUnknownAsync(UnknownSymbolDocument unknown, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(unknown);
var opts = _scoringOptions.Value;
return await _scoringService.ScoreUnknownAsync(unknown, opts, cancellationToken).ConfigureAwait(false);
}
public async Task<BatchDecayResult> RunNightlyDecayBatchAsync(CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
var subjects = await _repository.GetAllSubjectKeysAsync(cancellationToken).ConfigureAwait(false);
var maxSubjects = Math.Max(0, _options.Value.MaxSubjectsPerBatch);
if (maxSubjects > 0 && subjects.Count > maxSubjects)
{
subjects = subjects.Take(maxSubjects).ToArray();
}
_logger.LogInformation("Starting nightly unknowns decay batch for {Count} subjects", subjects.Count);
var totalUnknowns = 0;
var totalBandChanges = 0;
foreach (var subjectKey in subjects)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ApplyDecayAsync(subjectKey, cancellationToken).ConfigureAwait(false);
totalUnknowns += result.ProcessedCount;
totalBandChanges += result.BandChanges;
}
var endTime = _timeProvider.GetUtcNow();
var duration = endTime - startTime;
UnknownsDecayMetrics.BatchDurationSeconds.Record(duration.TotalSeconds);
var batchResult = new BatchDecayResult(
TotalSubjects: subjects.Count,
TotalUnknowns: totalUnknowns,
TotalBandChanges: totalBandChanges,
Duration: duration,
CompletedAt: endTime);
_logger.LogInformation(
"Completed nightly unknowns decay batch: subjects={TotalSubjects}, unknowns={TotalUnknowns}, bandChanges={TotalBandChanges}, duration={Duration}",
batchResult.TotalSubjects,
batchResult.TotalUnknowns,
batchResult.TotalBandChanges,
batchResult.Duration);
return batchResult;
}
}

View File

@@ -7,3 +7,4 @@ This file mirrors sprint work for the Signals module.
| `SIG-STORE-401-016` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Added reachability store repository APIs and models; callgraph ingestion now populates the store; Mongo index script at `ops/mongo/indices/reachability_store_indices.js`. |
| `UNCERTAINTY-SCHEMA-401-024` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented uncertainty tiers and scoring integration; see `src/Signals/StellaOps.Signals/Lattice/UncertaintyTier.cs` and `src/Signals/StellaOps.Signals/Lattice/ReachabilityLattice.cs`. |
| `UNCERTAINTY-SCORER-401-025` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Reachability risk score now uses configurable entropy weights and is aligned with `UncertaintyDocument.RiskScore`; tests cover tier/entropy scoring. |
| `UNKNOWNS-DECAY-3601-001` | `docs/implplan/SPRINT_3601_0001_0001_unknowns_decay_algorithm.md` | DOING (2025-12-15) | Implement decay worker/service, signal refresh hook, and deterministic unit/integration tests. |

View File

@@ -0,0 +1,71 @@
namespace StellaOps.Audit.ReplayToken;
/// <summary>
/// Extension for decision- and scoring-specific replay tokens.
/// </summary>
public static class DecisionReplayTokenExtensions
{
/// <summary>
/// Generates a replay token for a triage decision.
/// </summary>
public static ReplayToken GenerateForDecision(
this IReplayTokenGenerator generator,
string alertId,
string actorId,
string decisionStatus,
IEnumerable<string> evidenceHashes,
string? policyContext,
string? rulesVersion)
{
ArgumentNullException.ThrowIfNull(generator);
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
ArgumentException.ThrowIfNullOrWhiteSpace(decisionStatus);
ArgumentNullException.ThrowIfNull(evidenceHashes);
var request = new ReplayTokenRequest
{
InputHashes = new[] { alertId },
EvidenceHashes = evidenceHashes.ToList(),
RulesVersion = rulesVersion,
AdditionalContext = new Dictionary<string, string>
{
["actor_id"] = actorId,
["decision_status"] = decisionStatus,
["policy_context"] = policyContext ?? string.Empty
}
};
return generator.Generate(request);
}
/// <summary>
/// Generates a replay token for unknowns scoring.
/// </summary>
public static ReplayToken GenerateForScoring(
this IReplayTokenGenerator generator,
string subjectKey,
IEnumerable<string> feedManifests,
string scoringConfigVersion,
IEnumerable<string> inputHashes)
{
ArgumentNullException.ThrowIfNull(generator);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
ArgumentNullException.ThrowIfNull(feedManifests);
ArgumentException.ThrowIfNullOrWhiteSpace(scoringConfigVersion);
ArgumentNullException.ThrowIfNull(inputHashes);
var request = new ReplayTokenRequest
{
FeedManifests = feedManifests.ToList(),
ScoringConfigVersion = scoringConfigVersion,
InputHashes = inputHashes.ToList(),
AdditionalContext = new Dictionary<string, string>
{
["subject_key"] = subjectKey
}
};
return generator.Generate(request);
}
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Audit.ReplayToken;
/// <summary>
/// Generates deterministic replay tokens for audit and reproducibility.
/// </summary>
public interface IReplayTokenGenerator
{
/// <summary>
/// Generates a replay token from the given inputs.
/// </summary>
/// <param name="request">The inputs to hash.</param>
/// <returns>A deterministic replay token.</returns>
ReplayToken Generate(ReplayTokenRequest request);
/// <summary>
/// Verifies that inputs match a previously generated token.
/// </summary>
bool Verify(ReplayToken token, ReplayTokenRequest request);
}

View File

@@ -0,0 +1,18 @@
# StellaOps.Audit.ReplayToken
Deterministic replay token generation used to make triage decisions and scoring reproducible and audit-ready.
## Token format
`replay:v<version>:<algorithm>:<sha256_hex>`
Example:
`replay:v1.0:SHA-256:0123abcd...`
## Usage
- Create a `ReplayTokenRequest` with feed/rules/policy/input digests.
- Call `IReplayTokenGenerator.Generate(request)` to get a stable token value.
- Store the tokens `Canonical` string alongside immutable decision events.

View File

@@ -0,0 +1,69 @@
namespace StellaOps.Audit.ReplayToken;
/// <summary>
/// Generates CLI snippets for one-click reproduce functionality.
/// </summary>
public sealed class ReplayCliSnippetGenerator
{
/// <summary>
/// Generates a CLI command to reproduce a decision.
/// </summary>
public string GenerateDecisionReplay(
ReplayToken token,
string alertId,
string? feedManifestUri = null,
string? policyVersion = null)
{
ArgumentNullException.ThrowIfNull(token);
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
var parts = new List<string>
{
"stellaops",
"replay",
"decision",
$"--token {token.Value}",
$"--alert-id {alertId}"
};
if (!string.IsNullOrWhiteSpace(feedManifestUri))
{
parts.Add($"--feed-manifest {feedManifestUri.Trim()}");
}
if (!string.IsNullOrWhiteSpace(policyVersion))
{
parts.Add($"--policy-version {policyVersion.Trim()}");
}
return string.Join(" \\\n+ ", parts);
}
/// <summary>
/// Generates a CLI command to reproduce unknowns scoring.
/// </summary>
public string GenerateScoringReplay(
ReplayToken token,
string subjectKey,
string? configVersion = null)
{
ArgumentNullException.ThrowIfNull(token);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
var parts = new List<string>
{
"stellaops",
"replay",
"scoring",
$"--token {token.Value}",
$"--subject {subjectKey}"
};
if (!string.IsNullOrWhiteSpace(configVersion))
{
parts.Add($"--config-version {configVersion.Trim()}");
}
return string.Join(" \\\n+ ", parts);
}
}

View File

@@ -0,0 +1,94 @@
namespace StellaOps.Audit.ReplayToken;
/// <summary>
/// A deterministic, content-addressable replay token.
/// </summary>
public sealed class ReplayToken : IEquatable<ReplayToken>
{
public const string Scheme = "replay";
public const string DefaultAlgorithm = "SHA-256";
public const string DefaultVersion = "1.0";
/// <summary>
/// The token value (SHA-256 hash in hex).
/// </summary>
public string Value { get; }
/// <summary>
/// Algorithm used for hashing.
/// </summary>
public string Algorithm { get; }
/// <summary>
/// Version of the token generation algorithm.
/// </summary>
public string Version { get; }
/// <summary>
/// Timestamp when token was generated.
/// </summary>
public DateTimeOffset GeneratedAt { get; }
/// <summary>
/// Canonical representation for storage.
/// </summary>
public string Canonical => $"{Scheme}:v{Version}:{Algorithm}:{Value}";
public ReplayToken(string value, DateTimeOffset generatedAt, string? algorithm = null, string? version = null)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Token value cannot be empty.", nameof(value));
}
Value = value.Trim();
GeneratedAt = generatedAt;
Algorithm = string.IsNullOrWhiteSpace(algorithm) ? DefaultAlgorithm : algorithm.Trim();
Version = string.IsNullOrWhiteSpace(version) ? DefaultVersion : version.Trim();
}
/// <summary>
/// Parse a canonical token string.
/// </summary>
public static ReplayToken Parse(string canonical)
{
if (string.IsNullOrWhiteSpace(canonical))
{
throw new ArgumentException("Token cannot be empty.", nameof(canonical));
}
var parts = canonical.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 4 || !string.Equals(parts[0], Scheme, StringComparison.Ordinal))
{
throw new FormatException($"Invalid replay token format: {canonical}");
}
var versionPart = parts[1];
if (!versionPart.StartsWith("v", StringComparison.Ordinal) || versionPart.Length <= 1)
{
throw new FormatException($"Invalid replay token version: {canonical}");
}
var version = versionPart[1..];
var algorithm = parts[2];
var value = parts[3];
return new ReplayToken(value, DateTimeOffset.UnixEpoch, algorithm, version);
}
public override string ToString() => Canonical;
public bool Equals(ReplayToken? other)
{
if (other is null)
{
return false;
}
return string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase);
}
public override bool Equals(object? obj) => obj is ReplayToken other && Equals(other);
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value);
}

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Audit.ReplayToken;
/// <summary>
/// Inputs for replay token generation.
/// </summary>
public sealed class ReplayTokenRequest
{
/// <summary>
/// Feed manifest hashes (advisory sources).
/// </summary>
public IReadOnlyList<string> FeedManifests { get; init; } = Array.Empty<string>();
/// <summary>
/// Rule set version identifier.
/// </summary>
public string? RulesVersion { get; init; }
/// <summary>
/// Rule set content hash.
/// </summary>
public string? RulesHash { get; init; }
/// <summary>
/// Lattice policy version identifier.
/// </summary>
public string? LatticePolicyVersion { get; init; }
/// <summary>
/// Lattice policy content hash.
/// </summary>
public string? LatticePolicyHash { get; init; }
/// <summary>
/// Input artifact hashes (SBOMs, images, etc.).
/// </summary>
public IReadOnlyList<string> InputHashes { get; init; } = Array.Empty<string>();
/// <summary>
/// Scoring configuration version.
/// </summary>
public string? ScoringConfigVersion { get; init; }
/// <summary>
/// Evidence artifact hashes.
/// </summary>
public IReadOnlyList<string> EvidenceHashes { get; init; } = Array.Empty<string>();
/// <summary>
/// Additional context for extensibility.
/// </summary>
public IReadOnlyDictionary<string, string> AdditionalContext { get; init; } = new Dictionary<string, string>();
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Audit.ReplayToken;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddReplayTokenServices(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IReplayTokenGenerator, Sha256ReplayTokenGenerator>();
services.TryAddSingleton<ReplayCliSnippetGenerator>();
return services;
}
public static IServiceCollection AddReplayTokenServices(this IServiceCollection services, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(timeProvider);
services.AddSingleton(timeProvider);
services.TryAddSingleton<IReplayTokenGenerator, Sha256ReplayTokenGenerator>();
services.TryAddSingleton<ReplayCliSnippetGenerator>();
return services;
}
}

View File

@@ -0,0 +1,130 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
namespace StellaOps.Audit.ReplayToken;
/// <summary>
/// Generates replay tokens using SHA-256 hashing with deterministic canonicalization.
/// </summary>
public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
public Sha256ReplayTokenGenerator(ICryptoHash cryptoHash, TimeProvider timeProvider)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public ReplayToken Generate(ReplayTokenRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var canonical = Canonicalize(request);
var hashHex = ComputeHash(canonical);
return new ReplayToken(hashHex, _timeProvider.GetUtcNow());
}
public bool Verify(ReplayToken token, ReplayTokenRequest request)
{
ArgumentNullException.ThrowIfNull(token);
ArgumentNullException.ThrowIfNull(request);
var computed = Generate(request);
return string.Equals(token.Value, computed.Value, StringComparison.OrdinalIgnoreCase);
}
private string ComputeHash(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
return _cryptoHash.ComputeHashHex(bytes, HashAlgorithms.Sha256);
}
private static string? NormalizeValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static List<string> NormalizeSortedList(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return new List<string>();
}
var normalized = values
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => x.Trim())
.OrderBy(static x => x, StringComparer.Ordinal)
.ToList();
return normalized;
}
private static Dictionary<string, string> NormalizeSortedDictionary(IReadOnlyDictionary<string, string>? values)
{
if (values is null || values.Count == 0)
{
return new Dictionary<string, string>();
}
var normalized = values
.Where(static kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.Select(static kvp => new KeyValuePair<string, string>(kvp.Key.Trim(), kvp.Value?.Trim() ?? string.Empty))
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value, StringComparer.Ordinal);
return normalized;
}
/// <summary>
/// Produces deterministic canonical representation of inputs.
/// </summary>
private static string Canonicalize(ReplayTokenRequest request)
{
var canonical = new CanonicalReplayInput
{
Version = ReplayToken.DefaultVersion,
FeedManifests = NormalizeSortedList(request.FeedManifests),
RulesVersion = NormalizeValue(request.RulesVersion),
RulesHash = NormalizeValue(request.RulesHash),
LatticePolicyVersion = NormalizeValue(request.LatticePolicyVersion),
LatticePolicyHash = NormalizeValue(request.LatticePolicyHash),
InputHashes = NormalizeSortedList(request.InputHashes),
ScoringConfigVersion = NormalizeValue(request.ScoringConfigVersion),
EvidenceHashes = NormalizeSortedList(request.EvidenceHashes),
AdditionalContext = NormalizeSortedDictionary(request.AdditionalContext)
};
return JsonSerializer.Serialize(canonical, JsonOptions);
}
private sealed class CanonicalReplayInput
{
public required string Version { get; init; }
public required List<string> FeedManifests { get; init; }
public string? RulesVersion { get; init; }
public string? RulesHash { get; init; }
public string? LatticePolicyVersion { get; init; }
public string? LatticePolicyHash { get; init; }
public required List<string> InputHashes { get; init; }
public string? ScoringConfigVersion { get; init; }
public required List<string> EvidenceHashes { get; init; }
public required Dictionary<string, string> AdditionalContext { get; init; }
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Audit.ReplayToken</RootNamespace>
<Description>Deterministic replay token generation for audit and reproducibility</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -12,5 +12,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\\..\\..\\third_party\\forks\\AlexMAS.GostCryptography\\Source\\GostCryptography\\GostCryptography.csproj" />
<ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -13,5 +13,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -13,5 +13,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,5 +6,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,5 +6,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -14,5 +14,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -14,5 +14,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,118 @@
using StellaOps.Cryptography;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed class ReplayTokenGeneratorTests
{
[Fact]
public void Generate_SameInputs_ReturnsSameValue()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var fixedNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var timeProvider = new FixedTimeProvider(fixedNow);
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var request = new ReplayTokenRequest
{
FeedManifests = new[] { "sha256:bbb", "sha256:aaa" },
RulesVersion = "rules-v1",
RulesHash = "sha256:rules",
LatticePolicyVersion = "lattice-v1",
LatticePolicyHash = "sha256:lattice",
InputHashes = new[] { "sha256:input2", "sha256:input1" },
ScoringConfigVersion = "score-v1",
EvidenceHashes = new[] { "sha256:e2", "sha256:e1" },
AdditionalContext = new Dictionary<string, string>
{
["b"] = "2",
["a"] = "1"
}
};
var token1 = generator.Generate(request);
var token2 = generator.Generate(request);
Assert.Equal(token1.Value, token2.Value);
Assert.Equal(token1.Canonical, token2.Canonical);
}
[Fact]
public void Generate_IgnoresArrayOrdering()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var requestA = new ReplayTokenRequest
{
FeedManifests = new[] { "sha256:aaa", "sha256:bbb" },
InputHashes = new[] { "sha256:input1", "sha256:input2" }
};
var requestB = new ReplayTokenRequest
{
FeedManifests = new[] { "sha256:bbb", "sha256:aaa" },
InputHashes = new[] { "sha256:input2", "sha256:input1" }
};
var tokenA = generator.Generate(requestA);
var tokenB = generator.Generate(requestB);
Assert.Equal(tokenA.Value, tokenB.Value);
}
[Fact]
public void Verify_MatchingInputs_ReturnsTrue()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var token = generator.Generate(request);
Assert.True(generator.Verify(token, request));
}
[Fact]
public void Verify_DifferentInputs_ReturnsFalse()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var different = new ReplayTokenRequest { InputHashes = new[] { "sha256:other" } };
var token = generator.Generate(request);
Assert.False(generator.Verify(token, different));
}
[Fact]
public void ReplayToken_Parse_RoundTripsCanonical()
{
var token = new ReplayToken("0123456789abcdef", DateTimeOffset.UnixEpoch);
var parsed = ReplayToken.Parse(token.Canonical);
Assert.Equal(token.Value, parsed.Value);
Assert.Equal(token.Algorithm, parsed.Algorithm);
Assert.Equal(token.Version, parsed.Version);
}
[Theory]
[InlineData("")]
[InlineData("replay")]
[InlineData("replay:v1.0:SHA-256")]
[InlineData("other:v1.0:SHA-256:abc")]
public void ReplayToken_Parse_Invalid_Throws(string canonical)
{
Assert.ThrowsAny<Exception>(() => ReplayToken.Parse(canonical));
}
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Audit.ReplayToken\\StellaOps.Audit.ReplayToken.csproj" />
</ItemGroup>
</Project>