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
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:
@@ -1,6 +1,6 @@
|
|||||||
# SPRINT_1103_0001_0001 - Replay Token Library
|
# SPRINT_1103_0001_0001 - Replay Token Library
|
||||||
|
|
||||||
**Status:** TODO
|
**Status:** DONE
|
||||||
**Priority:** P0 - CRITICAL
|
**Priority:** P0 - CRITICAL
|
||||||
**Module:** Core Libraries, Attestor
|
**Module:** Core Libraries, Attestor
|
||||||
**Working Directory:** `src/__Libraries/StellaOps.Audit.ReplayToken/`
|
**Working Directory:** `src/__Libraries/StellaOps.Audit.ReplayToken/`
|
||||||
@@ -451,17 +451,17 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
| # | Task | Status | Assignee | Notes |
|
| # | Task | Status | Assignee | Notes |
|
||||||
|---|------|--------|----------|-------|
|
|---|------|--------|----------|-------|
|
||||||
| 1 | Create project `StellaOps.Audit.ReplayToken` | TODO | | New library |
|
| 1 | Create project `StellaOps.Audit.ReplayToken` | DONE | | New library |
|
||||||
| 2 | Implement `IReplayTokenGenerator` interface | TODO | | Per §3.1 |
|
| 2 | Implement `IReplayTokenGenerator` interface | DONE | | Per §3.1 |
|
||||||
| 3 | Implement `ReplayTokenRequest` model | TODO | | Per §3.2 |
|
| 3 | Implement `ReplayTokenRequest` model | DONE | | Per §3.2 |
|
||||||
| 4 | Implement `ReplayToken` model | TODO | | Per §3.3 |
|
| 4 | Implement `ReplayToken` model | DONE | | Per §3.3 |
|
||||||
| 5 | Implement `Sha256ReplayTokenGenerator` | TODO | | Per §3.4 |
|
| 5 | Implement `Sha256ReplayTokenGenerator` | DONE | | Per §3.4 |
|
||||||
| 6 | Implement decision token extensions | TODO | | Per §3.5 |
|
| 6 | Implement decision token extensions | DONE | | Per §3.5 |
|
||||||
| 7 | Implement CLI snippet generator | TODO | | Per §3.6 |
|
| 7 | Implement CLI snippet generator | DONE | | Per §3.6 |
|
||||||
| 8 | Add service registration | TODO | | Per §3.7 |
|
| 8 | Add service registration | DONE | | Per §3.7 |
|
||||||
| 9 | Write unit tests for determinism | TODO | | Verify same inputs → same output |
|
| 9 | Write unit tests for determinism | DONE | | Verify same inputs → same output |
|
||||||
| 10 | Write unit tests for verification | TODO | | |
|
| 10 | Write unit tests for verification | DONE | | |
|
||||||
| 11 | Document API in README | TODO | | |
|
| 11 | Document API in README | DONE | | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -469,22 +469,22 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
### 5.1 Determinism Requirements
|
### 5.1 Determinism Requirements
|
||||||
|
|
||||||
- [ ] Same inputs always produce same token
|
- [x] Same inputs always produce same token
|
||||||
- [ ] Array ordering doesn't affect output (sorted internally)
|
- [x] Array ordering doesn't affect output (sorted internally)
|
||||||
- [ ] Null handling is consistent
|
- [x] Null handling is consistent
|
||||||
- [ ] Token format is stable across versions
|
- [x] Token format is stable across versions
|
||||||
|
|
||||||
### 5.2 Verification Requirements
|
### 5.2 Verification Requirements
|
||||||
|
|
||||||
- [ ] `Verify()` returns true for matching inputs
|
- [x] `Verify()` returns true for matching inputs
|
||||||
- [ ] `Verify()` returns false for different inputs
|
- [x] `Verify()` returns false for different inputs
|
||||||
- [ ] Token parsing handles valid and invalid formats
|
- [x] Token parsing handles valid and invalid formats
|
||||||
|
|
||||||
### 5.3 CLI Requirements
|
### 5.3 CLI Requirements
|
||||||
|
|
||||||
- [ ] Generated CLI snippet is valid bash
|
- [x] Generated CLI snippet is valid bash
|
||||||
- [ ] Snippet includes all necessary parameters
|
- [x] Snippet includes all necessary parameters
|
||||||
- [ ] Snippet uses proper escaping
|
- [x] Snippet uses proper escaping
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPRINT_3101_0001_0001 - Scanner API Standardization
|
# SPRINT_3101_0001_0001 - Scanner API Standardization
|
||||||
|
|
||||||
**Status:** TODO
|
**Status:** DOING
|
||||||
**Priority:** P0 - CRITICAL
|
**Priority:** P0 - CRITICAL
|
||||||
**Module:** Scanner.WebService
|
**Module:** Scanner.WebService
|
||||||
**Working Directory:** `src/Scanner/StellaOps.Scanner.WebService/`
|
**Working Directory:** `src/Scanner/StellaOps.Scanner.WebService/`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPRINT_3102_0001_0001 - Postgres Call Graph Tables
|
# SPRINT_3102_0001_0001 - Postgres Call Graph Tables
|
||||||
|
|
||||||
**Status:** TODO
|
**Status:** DOING
|
||||||
**Priority:** P2 - MEDIUM
|
**Priority:** P2 - MEDIUM
|
||||||
**Module:** Signals, Scanner
|
**Module:** Signals, Scanner
|
||||||
**Working Directory:** `src/Signals/StellaOps.Signals.Storage.Postgres/`
|
**Working Directory:** `src/Signals/StellaOps.Signals.Storage.Postgres/`
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ info:
|
|||||||
idempotent submissions and async computation.
|
idempotent submissions and async computation.
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: /api
|
- url: https://scanner.stellaops.local/api/v1
|
||||||
description: Scanner service endpoint
|
description: Example Scanner endpoint
|
||||||
|
|
||||||
tags:
|
tags:
|
||||||
- name: Scans
|
- name: Scans
|
||||||
@@ -83,7 +83,7 @@ paths:
|
|||||||
description: SHA-256 digest for idempotency (RFC 9530)
|
description: SHA-256 digest for idempotency (RFC 9530)
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
|
example: "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
<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" />
|
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
6
src/Scanner/StellaOps.Scanner.WebService/TASKS.md
Normal file
6
src/Scanner/StellaOps.Scanner.WebService/TASKS.md
Normal 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. |
|
||||||
@@ -77,7 +77,7 @@ public sealed class HmacDsseSigningService : IDsseSigningService
|
|||||||
|
|
||||||
if (CryptographicOperations.FixedTimeEquals(expected.SignatureBytes, provided))
|
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"));
|
return Task.FromResult(new DsseVerificationOutcome(false, expected.IsTrusted, "dsse_sig_mismatch"));
|
||||||
@@ -141,4 +141,3 @@ public sealed class HmacDsseSigningService : IDsseSigningService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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`). |
|
||||||
24
src/Signals/StellaOps.Signals.Storage.Postgres/AGENTS.md
Normal file
24
src/Signals/StellaOps.Signals.Storage.Postgres/AGENTS.md
Normal 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).
|
||||||
5
src/Signals/StellaOps.Signals.Storage.Postgres/TASKS.md
Normal file
5
src/Signals/StellaOps.Signals.Storage.Postgres/TASKS.md
Normal 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. |
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -66,6 +66,11 @@ public sealed class UnknownsScoringOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int StalenessMaxDays { get; set; } = 14;
|
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 =====
|
// ===== BAND THRESHOLDS =====
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -80,6 +85,11 @@ public sealed class UnknownsScoringOptions
|
|||||||
|
|
||||||
// ===== RESCAN SCHEDULING =====
|
// ===== RESCAN SCHEDULING =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minutes until HOT items are rescanned. Default: 15
|
||||||
|
/// </summary>
|
||||||
|
public int HotRescanMinutes { get; set; } = 15;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hours until WARM items are rescanned. Default: 24
|
/// Hours until WARM items are rescanned. Default: 24
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ namespace StellaOps.Signals.Persistence;
|
|||||||
public sealed class InMemoryUnknownsRepository : IUnknownsRepository
|
public sealed class InMemoryUnknownsRepository : IUnknownsRepository
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, List<UnknownSymbolDocument>> _store = new(StringComparer.OrdinalIgnoreCase);
|
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)
|
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -59,12 +65,23 @@ public sealed class InMemoryUnknownsRepository : IUnknownsRepository
|
|||||||
return Task.CompletedTask;
|
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(
|
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
|
||||||
UnknownsBand band,
|
UnknownsBand band,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
var results = _store.Values
|
var results = _store.Values
|
||||||
.SelectMany(x => x)
|
.SelectMany(x => x)
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ builder.Services.AddSingleton<IReachabilityFactRepository>(sp =>
|
|||||||
return new ReachabilityFactCacheDecorator(inner, cache);
|
return new ReachabilityFactCacheDecorator(inner, cache);
|
||||||
});
|
});
|
||||||
builder.Services.AddSingleton<IUnknownsRepository, InMemoryUnknownsRepository>();
|
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.AddSingleton<IReachabilityStoreRepository, InMemoryReachabilityStoreRepository>();
|
||||||
builder.Services.AddHttpClient<RouterEventsPublisher>((sp, client) =>
|
builder.Services.AddHttpClient<RouterEventsPublisher>((sp, client) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
63
src/Signals/StellaOps.Signals/Services/NightlyDecayWorker.cs
Normal file
63
src/Signals/StellaOps.Signals/Services/NightlyDecayWorker.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
143
src/Signals/StellaOps.Signals/Services/UnknownsDecayService.cs
Normal file
143
src/Signals/StellaOps.Signals/Services/UnknownsDecayService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`. |
|
| `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-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. |
|
| `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. |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
18
src/__Libraries/StellaOps.Audit.ReplayToken/README.md
Normal file
18
src/__Libraries/StellaOps.Audit.ReplayToken/README.md
Normal 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 token’s `Canonical` string alongside immutable decision events.
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/__Libraries/StellaOps.Audit.ReplayToken/ReplayToken.cs
Normal file
94
src/__Libraries/StellaOps.Audit.ReplayToken/ReplayToken.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -12,5 +12,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
||||||
<ProjectReference Include="..\\..\\..\\third_party\\forks\\AlexMAS.GostCryptography\\Source\\GostCryptography\\GostCryptography.csproj" />
|
<ProjectReference Include="..\\..\\..\\third_party\\forks\\AlexMAS.GostCryptography\\Source\\GostCryptography\\GostCryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -13,5 +13,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -13,5 +13,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,5 +6,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,5 +6,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -14,5 +14,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -14,5 +14,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user