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
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (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

@@ -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>