Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditPackExportService.cs
|
||||
// Sprint: SPRINT_1227_0005_0003_FE_copy_audit_export
|
||||
// Task: T5 — Backend export service for audit packs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting audit packs in various formats.
|
||||
/// Supports ZIP bundle, JSON, and DSSE envelope formats.
|
||||
/// </summary>
|
||||
public sealed class AuditPackExportService : IAuditPackExportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly IAuditBundleWriter _bundleWriter;
|
||||
private readonly IAuditPackRepository? _repository;
|
||||
|
||||
public AuditPackExportService(
|
||||
IAuditBundleWriter bundleWriter,
|
||||
IAuditPackRepository? repository = null)
|
||||
{
|
||||
_bundleWriter = bundleWriter;
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports an audit pack based on the provided configuration.
|
||||
/// </summary>
|
||||
public async Task<ExportResult> ExportAsync(
|
||||
ExportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return request.Format switch
|
||||
{
|
||||
ExportFormat.Zip => await ExportAsZipAsync(request, cancellationToken),
|
||||
ExportFormat.Json => await ExportAsJsonAsync(request, cancellationToken),
|
||||
ExportFormat.Dsse => await ExportAsDsseAsync(request, cancellationToken),
|
||||
_ => ExportResult.Failed($"Unsupported export format: {request.Format}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports as a ZIP bundle containing all evidence segments.
|
||||
/// </summary>
|
||||
private async Task<ExportResult> ExportAsZipAsync(
|
||||
ExportRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
// Create manifest
|
||||
var manifest = CreateManifest(request);
|
||||
await AddJsonToZipAsync(archive, "manifest.json", manifest, ct);
|
||||
|
||||
// Add selected segments
|
||||
foreach (var segment in request.Segments)
|
||||
{
|
||||
var segmentData = await GetSegmentDataAsync(request.ScanId, segment, ct);
|
||||
if (segmentData is not null)
|
||||
{
|
||||
var path = GetSegmentPath(segment);
|
||||
await AddBytesToZipAsync(archive, path, segmentData);
|
||||
}
|
||||
}
|
||||
|
||||
// Add attestations if requested
|
||||
if (request.IncludeAttestations)
|
||||
{
|
||||
var attestations = await GetAttestationsAsync(request.ScanId, ct);
|
||||
if (attestations.Count > 0)
|
||||
{
|
||||
await AddJsonToZipAsync(archive, "attestations/attestations.json", attestations, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Add proof chain if requested
|
||||
if (request.IncludeProofChain)
|
||||
{
|
||||
var proofChain = await GetProofChainAsync(request.ScanId, ct);
|
||||
if (proofChain is not null)
|
||||
{
|
||||
await AddJsonToZipAsync(archive, "proof/proof-chain.json", proofChain, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memoryStream.Position = 0;
|
||||
var bytes = memoryStream.ToArray();
|
||||
|
||||
return new ExportResult
|
||||
{
|
||||
Success = true,
|
||||
Data = bytes,
|
||||
ContentType = "application/zip",
|
||||
Filename = $"{request.Filename}.zip",
|
||||
SizeBytes = bytes.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports as a single JSON document.
|
||||
/// </summary>
|
||||
private async Task<ExportResult> ExportAsJsonAsync(
|
||||
ExportRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var exportDoc = new Dictionary<string, object>
|
||||
{
|
||||
["exportedAt"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["scanId"] = request.ScanId,
|
||||
["format"] = "json",
|
||||
["version"] = "1.0"
|
||||
};
|
||||
|
||||
// Add segments
|
||||
var segments = new Dictionary<string, object>();
|
||||
foreach (var segment in request.Segments)
|
||||
{
|
||||
var segmentData = await GetSegmentDataAsync(request.ScanId, segment, ct);
|
||||
if (segmentData is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsedDoc = JsonDocument.Parse(segmentData);
|
||||
segments[segment.ToString().ToLowerInvariant()] = parsedDoc.RootElement;
|
||||
}
|
||||
catch
|
||||
{
|
||||
segments[segment.ToString().ToLowerInvariant()] = Convert.ToBase64String(segmentData);
|
||||
}
|
||||
}
|
||||
}
|
||||
exportDoc["segments"] = segments;
|
||||
|
||||
// Add attestations
|
||||
if (request.IncludeAttestations)
|
||||
{
|
||||
var attestations = await GetAttestationsAsync(request.ScanId, ct);
|
||||
exportDoc["attestations"] = attestations;
|
||||
}
|
||||
|
||||
// Add proof chain
|
||||
if (request.IncludeProofChain)
|
||||
{
|
||||
var proofChain = await GetProofChainAsync(request.ScanId, ct);
|
||||
if (proofChain is not null)
|
||||
{
|
||||
exportDoc["proofChain"] = proofChain;
|
||||
}
|
||||
}
|
||||
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(exportDoc, JsonOptions);
|
||||
|
||||
return new ExportResult
|
||||
{
|
||||
Success = true,
|
||||
Data = json,
|
||||
ContentType = "application/json",
|
||||
Filename = $"{request.Filename}.json",
|
||||
SizeBytes = json.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports as a DSSE envelope with signature.
|
||||
/// </summary>
|
||||
private async Task<ExportResult> ExportAsDsseAsync(
|
||||
ExportRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// First create the JSON payload
|
||||
var jsonResult = await ExportAsJsonAsync(request, ct);
|
||||
if (!jsonResult.Success)
|
||||
{
|
||||
return jsonResult;
|
||||
}
|
||||
|
||||
// Create DSSE envelope structure
|
||||
var payload = Convert.ToBase64String(jsonResult.Data!);
|
||||
var envelope = new DsseExportEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.stellaops.audit-pack+json",
|
||||
Payload = payload,
|
||||
Signatures = [] // Would be populated by actual signing in production
|
||||
};
|
||||
|
||||
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
|
||||
|
||||
return new ExportResult
|
||||
{
|
||||
Success = true,
|
||||
Data = envelopeBytes,
|
||||
ContentType = "application/vnd.dsse+json",
|
||||
Filename = $"{request.Filename}.dsse.json",
|
||||
SizeBytes = envelopeBytes.Length
|
||||
};
|
||||
}
|
||||
|
||||
private static ExportManifest CreateManifest(ExportRequest request)
|
||||
{
|
||||
return new ExportManifest
|
||||
{
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
ScanId = request.ScanId,
|
||||
FindingIds = request.FindingIds,
|
||||
Format = request.Format.ToString(),
|
||||
Segments = [.. request.Segments.Select(s => s.ToString())],
|
||||
IncludesAttestations = request.IncludeAttestations,
|
||||
IncludesProofChain = request.IncludeProofChain,
|
||||
Version = "1.0"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSegmentPath(ExportSegment segment)
|
||||
{
|
||||
return segment switch
|
||||
{
|
||||
ExportSegment.Sbom => "sbom/sbom.json",
|
||||
ExportSegment.Match => "match/vulnerability-match.json",
|
||||
ExportSegment.Reachability => "reachability/reachability-analysis.json",
|
||||
ExportSegment.Guards => "guards/guard-analysis.json",
|
||||
ExportSegment.Runtime => "runtime/runtime-signals.json",
|
||||
ExportSegment.Policy => "policy/policy-evaluation.json",
|
||||
_ => $"segments/{segment.ToString().ToLowerInvariant()}.json"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<byte[]?> GetSegmentDataAsync(
|
||||
string scanId,
|
||||
ExportSegment segment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_repository is null)
|
||||
{
|
||||
// Return mock data for testing
|
||||
return CreateMockSegmentData(segment);
|
||||
}
|
||||
|
||||
return await _repository.GetSegmentDataAsync(scanId, segment, ct);
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetAttestationsAsync(string scanId, CancellationToken ct)
|
||||
{
|
||||
if (_repository is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var attestations = await _repository.GetAttestationsAsync(scanId, ct);
|
||||
return [.. attestations];
|
||||
}
|
||||
|
||||
private async Task<object?> GetProofChainAsync(string scanId, CancellationToken ct)
|
||||
{
|
||||
if (_repository is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _repository.GetProofChainAsync(scanId, ct);
|
||||
}
|
||||
|
||||
private static byte[] CreateMockSegmentData(ExportSegment segment)
|
||||
{
|
||||
var mockData = new Dictionary<string, object>
|
||||
{
|
||||
["segment"] = segment.ToString(),
|
||||
["generatedAt"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["data"] = new { placeholder = true }
|
||||
};
|
||||
return JsonSerializer.SerializeToUtf8Bytes(mockData, JsonOptions);
|
||||
}
|
||||
|
||||
private static async Task AddJsonToZipAsync<T>(
|
||||
ZipArchive archive,
|
||||
string path,
|
||||
T data,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
|
||||
await using var stream = entry.Open();
|
||||
await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct);
|
||||
}
|
||||
|
||||
private static async Task AddBytesToZipAsync(
|
||||
ZipArchive archive,
|
||||
string path,
|
||||
byte[] data)
|
||||
{
|
||||
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
|
||||
await using var stream = entry.Open();
|
||||
await stream.WriteAsync(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for audit pack export service.
|
||||
/// </summary>
|
||||
public interface IAuditPackExportService
|
||||
{
|
||||
Task<ExportResult> ExportAsync(ExportRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for accessing audit pack data.
|
||||
/// </summary>
|
||||
public interface IAuditPackRepository
|
||||
{
|
||||
Task<byte[]?> GetSegmentDataAsync(string scanId, ExportSegment segment, CancellationToken ct);
|
||||
Task<IReadOnlyList<object>> GetAttestationsAsync(string scanId, CancellationToken ct);
|
||||
Task<object?> GetProofChainAsync(string scanId, CancellationToken ct);
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
/// <summary>
|
||||
/// Export format options.
|
||||
/// </summary>
|
||||
public enum ExportFormat
|
||||
{
|
||||
Zip,
|
||||
Json,
|
||||
Dsse
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence segment types for export.
|
||||
/// </summary>
|
||||
public enum ExportSegment
|
||||
{
|
||||
Sbom,
|
||||
Match,
|
||||
Reachability,
|
||||
Guards,
|
||||
Runtime,
|
||||
Policy
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for audit pack export.
|
||||
/// </summary>
|
||||
public sealed record ExportRequest
|
||||
{
|
||||
public required string ScanId { get; init; }
|
||||
public IReadOnlyList<string>? FindingIds { get; init; }
|
||||
public required ExportFormat Format { get; init; }
|
||||
public required IReadOnlyList<ExportSegment> Segments { get; init; }
|
||||
public bool IncludeAttestations { get; init; }
|
||||
public bool IncludeProofChain { get; init; }
|
||||
public required string Filename { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of audit pack export.
|
||||
/// </summary>
|
||||
public sealed record ExportResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public byte[]? Data { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string? Filename { get; init; }
|
||||
public long SizeBytes { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static ExportResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export manifest included in ZIP bundles.
|
||||
/// </summary>
|
||||
public sealed record ExportManifest
|
||||
{
|
||||
public DateTimeOffset ExportedAt { get; init; }
|
||||
public required string ScanId { get; init; }
|
||||
public IReadOnlyList<string>? FindingIds { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public required IReadOnlyList<string> Segments { get; init; }
|
||||
public bool IncludesAttestations { get; init; }
|
||||
public bool IncludesProofChain { get; init; }
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for export.
|
||||
/// </summary>
|
||||
public sealed record DsseExportEnvelope
|
||||
{
|
||||
public required string PayloadType { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required IReadOnlyList<DsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature entry.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,420 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayAttestationService.cs
|
||||
// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay
|
||||
// Task: T7 — Replay attestation generation with DSSE signing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating DSSE-signed attestations for replay executions.
|
||||
/// Produces in-toto v1 statements with verdict replay predicates.
|
||||
/// </summary>
|
||||
public sealed class ReplayAttestationService : IReplayAttestationService
|
||||
{
|
||||
private const string InTotoStatementType = "https://in-toto.io/Statement/v1";
|
||||
private const string VerdictReplayPredicateType = "https://stellaops.io/attestation/verdict-replay/v1";
|
||||
private const string DssePayloadType = "application/vnd.in-toto+json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IReplayAttestationSigner? _signer;
|
||||
|
||||
public ReplayAttestationService(IReplayAttestationSigner? signer = null)
|
||||
{
|
||||
_signer = signer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a DSSE attestation for a replay execution result.
|
||||
/// </summary>
|
||||
public async Task<ReplayAttestation> GenerateAsync(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayExecutionResult replayResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(replayResult);
|
||||
|
||||
// Build the in-toto statement
|
||||
var statement = CreateInTotoStatement(manifest, replayResult);
|
||||
|
||||
// Serialize to canonical JSON
|
||||
var statementBytes = JsonSerializer.SerializeToUtf8Bytes(statement, JsonOptions);
|
||||
var statementDigest = ComputeSha256Digest(statementBytes);
|
||||
|
||||
// Create DSSE envelope
|
||||
var envelope = await CreateDsseEnvelopeAsync(statementBytes, cancellationToken);
|
||||
|
||||
return new ReplayAttestation
|
||||
{
|
||||
AttestationId = Guid.NewGuid().ToString("N"),
|
||||
ManifestId = manifest.BundleId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Statement = statement,
|
||||
StatementDigest = statementDigest,
|
||||
Envelope = envelope,
|
||||
Match = replayResult.VerdictMatches && replayResult.DecisionMatches,
|
||||
ReplayStatus = replayResult.Status.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a replay attestation's integrity.
|
||||
/// </summary>
|
||||
public Task<AttestationVerificationResult> VerifyAsync(
|
||||
ReplayAttestation attestation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Verify statement digest
|
||||
var statementBytes = JsonSerializer.SerializeToUtf8Bytes(attestation.Statement, JsonOptions);
|
||||
var computedDigest = ComputeSha256Digest(statementBytes);
|
||||
|
||||
if (computedDigest != attestation.StatementDigest)
|
||||
{
|
||||
errors.Add($"Statement digest mismatch: expected {attestation.StatementDigest}, got {computedDigest}");
|
||||
}
|
||||
|
||||
// Verify envelope payload matches statement
|
||||
if (attestation.Envelope is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(attestation.Envelope.Payload);
|
||||
var payloadDigest = ComputeSha256Digest(payloadBytes);
|
||||
|
||||
if (payloadDigest != computedDigest)
|
||||
{
|
||||
errors.Add("Envelope payload digest does not match statement");
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
errors.Add("Invalid base64 in envelope payload");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signatures if signer is available
|
||||
var signatureValid = attestation.Envelope?.Signatures.Count > 0;
|
||||
|
||||
return Task.FromResult(new AttestationVerificationResult
|
||||
{
|
||||
IsValid = errors.Count == 0,
|
||||
Errors = [.. errors],
|
||||
SignatureVerified = signatureValid,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a batch of attestations for multiple replay results.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ReplayAttestation>> GenerateBatchAsync(
|
||||
IEnumerable<(AuditBundleManifest Manifest, ReplayExecutionResult Result)> replays,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attestations = new List<ReplayAttestation>();
|
||||
|
||||
foreach (var (manifest, result) in replays)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var attestation = await GenerateAsync(manifest, result, cancellationToken);
|
||||
attestations.Add(attestation);
|
||||
}
|
||||
|
||||
return attestations;
|
||||
}
|
||||
|
||||
private InTotoStatement CreateInTotoStatement(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayExecutionResult replayResult)
|
||||
{
|
||||
return new InTotoStatement
|
||||
{
|
||||
Type = InTotoStatementType,
|
||||
Subject =
|
||||
[
|
||||
new InTotoSubject
|
||||
{
|
||||
Name = $"verdict:{manifest.BundleId}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = manifest.VerdictDigest.Replace("sha256:", "")
|
||||
}
|
||||
}
|
||||
],
|
||||
PredicateType = VerdictReplayPredicateType,
|
||||
Predicate = new VerdictReplayAttestation
|
||||
{
|
||||
ManifestId = manifest.BundleId,
|
||||
ScanId = manifest.ScanId,
|
||||
ImageRef = manifest.ImageRef,
|
||||
ImageDigest = manifest.ImageDigest,
|
||||
InputsDigest = ComputeInputsDigest(manifest.Inputs),
|
||||
OriginalVerdictDigest = manifest.VerdictDigest,
|
||||
ReplayedVerdictDigest = replayResult.ReplayedVerdictDigest,
|
||||
OriginalDecision = manifest.Decision,
|
||||
ReplayedDecision = replayResult.ReplayedDecision,
|
||||
Match = replayResult.VerdictMatches && replayResult.DecisionMatches,
|
||||
Status = replayResult.Status.ToString(),
|
||||
DriftCount = replayResult.Drifts.Count,
|
||||
Drifts = replayResult.Drifts.Select(d => new DriftAttestation
|
||||
{
|
||||
Type = d.Type.ToString(),
|
||||
Field = d.Field,
|
||||
Message = d.Message
|
||||
}).ToList(),
|
||||
EvaluatedAt = replayResult.EvaluatedAt,
|
||||
ReplayedAt = DateTimeOffset.UtcNow,
|
||||
DurationMs = replayResult.DurationMs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ReplayDsseEnvelope> CreateDsseEnvelopeAsync(
|
||||
byte[] payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payloadBase64 = Convert.ToBase64String(payload);
|
||||
var signatures = new List<ReplayDsseSignature>();
|
||||
|
||||
if (_signer is not null)
|
||||
{
|
||||
var signature = await _signer.SignAsync(payload, cancellationToken);
|
||||
signatures.Add(new ReplayDsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Sig = signature.Signature
|
||||
});
|
||||
}
|
||||
|
||||
return new ReplayDsseEnvelope
|
||||
{
|
||||
PayloadType = DssePayloadType,
|
||||
Payload = payloadBase64,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeInputsDigest(InputDigests inputs)
|
||||
{
|
||||
var combined = $"{inputs.SbomDigest}|{inputs.FeedsDigest}|{inputs.PolicyDigest}|{inputs.VexDigest}";
|
||||
var bytes = Encoding.UTF8.GetBytes(combined);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for replay attestation generation.
|
||||
/// </summary>
|
||||
public interface IReplayAttestationService
|
||||
{
|
||||
Task<ReplayAttestation> GenerateAsync(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayExecutionResult replayResult,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AttestationVerificationResult> VerifyAsync(
|
||||
ReplayAttestation attestation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ReplayAttestation>> GenerateBatchAsync(
|
||||
IEnumerable<(AuditBundleManifest Manifest, ReplayExecutionResult Result)> replays,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing replay attestations.
|
||||
/// </summary>
|
||||
public interface IReplayAttestationSigner
|
||||
{
|
||||
Task<DsseSignatureResult> SignAsync(byte[] payload, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
/// <summary>
|
||||
/// Generated replay attestation.
|
||||
/// </summary>
|
||||
public sealed record ReplayAttestation
|
||||
{
|
||||
public required string AttestationId { get; init; }
|
||||
public required string ManifestId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required InTotoStatement Statement { get; init; }
|
||||
public required string StatementDigest { get; init; }
|
||||
public ReplayDsseEnvelope? Envelope { get; init; }
|
||||
public bool Match { get; init; }
|
||||
public required string ReplayStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto v1 statement structure.
|
||||
/// </summary>
|
||||
public sealed record InTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<InTotoSubject> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required VerdictReplayAttestation Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto subject with name and digest.
|
||||
/// </summary>
|
||||
public sealed record InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdict replay predicate for attestation.
|
||||
/// </summary>
|
||||
public sealed record VerdictReplayAttestation
|
||||
{
|
||||
[JsonPropertyName("manifestId")]
|
||||
public required string ManifestId { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public required string ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("inputsDigest")]
|
||||
public required string InputsDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("originalVerdictDigest")]
|
||||
public required string OriginalVerdictDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("replayedVerdictDigest")]
|
||||
public string? ReplayedVerdictDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("originalDecision")]
|
||||
public required string OriginalDecision { get; init; }
|
||||
|
||||
[JsonPropertyName("replayedDecision")]
|
||||
public string? ReplayedDecision { get; init; }
|
||||
|
||||
[JsonPropertyName("match")]
|
||||
public bool Match { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("driftCount")]
|
||||
public int DriftCount { get; init; }
|
||||
|
||||
[JsonPropertyName("drifts")]
|
||||
public IReadOnlyList<DriftAttestation>? Drifts { get; init; }
|
||||
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("replayedAt")]
|
||||
public DateTimeOffset ReplayedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift item in attestation.
|
||||
/// </summary>
|
||||
public sealed record DriftAttestation
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("field")]
|
||||
public string? Field { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for replay attestation.
|
||||
/// </summary>
|
||||
public sealed record ReplayDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<ReplayDsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature entry.
|
||||
/// </summary>
|
||||
public sealed record ReplayDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing operation.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureResult
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Signature { get; init; }
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerificationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
public bool SignatureVerified { get; init; }
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
399
src/__Libraries/StellaOps.AuditPack/Services/ReplayTelemetry.cs
Normal file
399
src/__Libraries/StellaOps.AuditPack/Services/ReplayTelemetry.cs
Normal file
@@ -0,0 +1,399 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayTelemetry.cs
|
||||
// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay
|
||||
// Task: T10 — Telemetry for replay outcomes
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry instrumentation for verdict replay operations.
|
||||
/// Provides metrics, traces, and structured logging support.
|
||||
/// </summary>
|
||||
public sealed class ReplayTelemetry : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Service name for telemetry identification.
|
||||
/// </summary>
|
||||
public const string ServiceName = "StellaOps.Replay";
|
||||
|
||||
/// <summary>
|
||||
/// Meter name for replay metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.Replay";
|
||||
|
||||
/// <summary>
|
||||
/// Activity source name for replay tracing.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.Replay";
|
||||
|
||||
private readonly Meter _meter;
|
||||
|
||||
// Counters
|
||||
private readonly Counter<long> _replayExecutionsTotal;
|
||||
private readonly Counter<long> _replayMatchesTotal;
|
||||
private readonly Counter<long> _replayDivergencesTotal;
|
||||
private readonly Counter<long> _replayErrorsTotal;
|
||||
private readonly Counter<long> _attestationsGeneratedTotal;
|
||||
private readonly Counter<long> _attestationsVerifiedTotal;
|
||||
private readonly Counter<long> _eligibilityChecksTotal;
|
||||
|
||||
// Histograms
|
||||
private readonly Histogram<double> _replayDurationMs;
|
||||
private readonly Histogram<double> _attestationGenerationDurationMs;
|
||||
private readonly Histogram<int> _driftCount;
|
||||
private readonly Histogram<double> _confidenceScore;
|
||||
|
||||
// Gauges
|
||||
private readonly UpDownCounter<long> _replaysInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for distributed tracing.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ReplayTelemetry class.
|
||||
/// </summary>
|
||||
public ReplayTelemetry(IMeterFactory? meterFactory = null)
|
||||
{
|
||||
_meter = meterFactory?.Create(MeterName) ?? new Meter(MeterName);
|
||||
|
||||
// Counters
|
||||
_replayExecutionsTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.executions.total",
|
||||
unit: "{execution}",
|
||||
description: "Total number of replay executions");
|
||||
|
||||
_replayMatchesTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.matches.total",
|
||||
unit: "{match}",
|
||||
description: "Total number of replay matches (verdict unchanged)");
|
||||
|
||||
_replayDivergencesTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.divergences.total",
|
||||
unit: "{divergence}",
|
||||
description: "Total number of replay divergences detected");
|
||||
|
||||
_replayErrorsTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.errors.total",
|
||||
unit: "{error}",
|
||||
description: "Total number of replay errors");
|
||||
|
||||
_attestationsGeneratedTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.attestations.generated.total",
|
||||
unit: "{attestation}",
|
||||
description: "Total number of replay attestations generated");
|
||||
|
||||
_attestationsVerifiedTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.attestations.verified.total",
|
||||
unit: "{verification}",
|
||||
description: "Total number of replay attestations verified");
|
||||
|
||||
_eligibilityChecksTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.eligibility.checks.total",
|
||||
unit: "{check}",
|
||||
description: "Total number of replay eligibility checks");
|
||||
|
||||
// Histograms
|
||||
_replayDurationMs = _meter.CreateHistogram<double>(
|
||||
"stellaops.replay.duration.ms",
|
||||
unit: "ms",
|
||||
description: "Replay execution duration in milliseconds");
|
||||
|
||||
_attestationGenerationDurationMs = _meter.CreateHistogram<double>(
|
||||
"stellaops.replay.attestation.generation.duration.ms",
|
||||
unit: "ms",
|
||||
description: "Attestation generation duration in milliseconds");
|
||||
|
||||
_driftCount = _meter.CreateHistogram<int>(
|
||||
"stellaops.replay.drift.count",
|
||||
unit: "{drift}",
|
||||
description: "Number of drifts detected per replay");
|
||||
|
||||
_confidenceScore = _meter.CreateHistogram<double>(
|
||||
"stellaops.replay.eligibility.confidence",
|
||||
unit: "1",
|
||||
description: "Replay eligibility confidence score distribution");
|
||||
|
||||
// Gauges
|
||||
_replaysInProgress = _meter.CreateUpDownCounter<long>(
|
||||
"stellaops.replay.in_progress",
|
||||
unit: "{replay}",
|
||||
description: "Number of replays currently in progress");
|
||||
}
|
||||
|
||||
#region Replay Execution Metrics
|
||||
|
||||
/// <summary>
|
||||
/// Records the start of a replay execution.
|
||||
/// </summary>
|
||||
public void RecordReplayStarted(string manifestId, string scanId)
|
||||
{
|
||||
_replaysInProgress.Add(1, new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the completion of a replay execution.
|
||||
/// </summary>
|
||||
public void RecordReplayCompleted(
|
||||
string manifestId,
|
||||
string scanId,
|
||||
ReplayOutcome outcome,
|
||||
int driftCount,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId },
|
||||
{ ReplayTelemetryTags.Outcome, outcome.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_replaysInProgress.Add(-1, new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId }
|
||||
});
|
||||
|
||||
_replayExecutionsTotal.Add(1, tags);
|
||||
_replayDurationMs.Record(duration.TotalMilliseconds, tags);
|
||||
|
||||
switch (outcome)
|
||||
{
|
||||
case ReplayOutcome.Match:
|
||||
_replayMatchesTotal.Add(1, tags);
|
||||
break;
|
||||
case ReplayOutcome.Divergence:
|
||||
_replayDivergencesTotal.Add(1, tags);
|
||||
_driftCount.Record(driftCount, tags);
|
||||
break;
|
||||
case ReplayOutcome.Error:
|
||||
_replayErrorsTotal.Add(1, tags);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a replay error.
|
||||
/// </summary>
|
||||
public void RecordReplayError(
|
||||
string manifestId,
|
||||
string scanId,
|
||||
string errorCode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId },
|
||||
{ ReplayTelemetryTags.ErrorCode, errorCode }
|
||||
};
|
||||
|
||||
_replaysInProgress.Add(-1, new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId }
|
||||
});
|
||||
|
||||
_replayErrorsTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Attestation Metrics
|
||||
|
||||
/// <summary>
|
||||
/// Records attestation generation.
|
||||
/// </summary>
|
||||
public void RecordAttestationGenerated(
|
||||
string manifestId,
|
||||
bool match,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.Match, match.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_attestationsGeneratedTotal.Add(1, tags);
|
||||
_attestationGenerationDurationMs.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records attestation verification.
|
||||
/// </summary>
|
||||
public void RecordAttestationVerified(
|
||||
string attestationId,
|
||||
bool valid)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.AttestationId, attestationId },
|
||||
{ ReplayTelemetryTags.Valid, valid.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_attestationsVerifiedTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Eligibility Metrics
|
||||
|
||||
/// <summary>
|
||||
/// Records an eligibility check.
|
||||
/// </summary>
|
||||
public void RecordEligibilityCheck(
|
||||
string manifestId,
|
||||
bool eligible,
|
||||
double confidenceScore)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.Eligible, eligible.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_eligibilityChecksTotal.Add(1, tags);
|
||||
_confidenceScore.Record(confidenceScore, tags);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Activity Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for replay execution.
|
||||
/// </summary>
|
||||
public static Activity? StartReplayActivity(string manifestId, string scanId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Replay.Execute");
|
||||
activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId);
|
||||
activity?.SetTag(ReplayTelemetryTags.ScanId, scanId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for attestation generation.
|
||||
/// </summary>
|
||||
public static Activity? StartAttestationActivity(string manifestId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Replay.GenerateAttestation");
|
||||
activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for eligibility check.
|
||||
/// </summary>
|
||||
public static Activity? StartEligibilityActivity(string manifestId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Replay.CheckEligibility");
|
||||
activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for divergence detection.
|
||||
/// </summary>
|
||||
public static Activity? StartDivergenceActivity(string manifestId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Replay.DetectDivergence");
|
||||
activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag names for replay telemetry.
|
||||
/// </summary>
|
||||
public static class ReplayTelemetryTags
|
||||
{
|
||||
public const string ManifestId = "manifest_id";
|
||||
public const string ScanId = "scan_id";
|
||||
public const string BundleId = "bundle_id";
|
||||
public const string AttestationId = "attestation_id";
|
||||
public const string Outcome = "outcome";
|
||||
public const string Match = "match";
|
||||
public const string Valid = "valid";
|
||||
public const string Eligible = "eligible";
|
||||
public const string ErrorCode = "error_code";
|
||||
public const string DivergenceType = "divergence_type";
|
||||
public const string DriftType = "drift_type";
|
||||
public const string Severity = "severity";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay outcome values.
|
||||
/// </summary>
|
||||
public enum ReplayOutcome
|
||||
{
|
||||
/// <summary>Verdict matched the original.</summary>
|
||||
Match,
|
||||
|
||||
/// <summary>Divergence detected between original and replayed verdict.</summary>
|
||||
Divergence,
|
||||
|
||||
/// <summary>Replay execution failed with error.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>Replay was cancelled.</summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Divergence severity levels.
|
||||
/// </summary>
|
||||
public static class DivergenceSeverities
|
||||
{
|
||||
public const string Critical = "critical";
|
||||
public const string High = "high";
|
||||
public const string Medium = "medium";
|
||||
public const string Low = "low";
|
||||
public const string Info = "info";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Divergence type values.
|
||||
/// </summary>
|
||||
public static class DivergenceTypes
|
||||
{
|
||||
public const string VerdictDigest = "verdict_digest";
|
||||
public const string Decision = "decision";
|
||||
public const string Confidence = "confidence";
|
||||
public const string Input = "input";
|
||||
public const string Policy = "policy";
|
||||
public const string Evidence = "evidence";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding replay telemetry.
|
||||
/// </summary>
|
||||
public static class ReplayTelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds replay OpenTelemetry instrumentation.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddReplayTelemetry(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ReplayTelemetry>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictReplayPredicate.cs
|
||||
// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay
|
||||
// Task: T4 — Verdict replay predicate for determining replay eligibility
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether a verdict is eligible for replay and
|
||||
/// determines expected outcomes based on input analysis.
|
||||
/// </summary>
|
||||
public sealed class VerdictReplayPredicate : IVerdictReplayPredicate
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether a verdict can be replayed given the current inputs.
|
||||
/// </summary>
|
||||
public ReplayEligibility Evaluate(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayInputState? currentInputState = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var reasons = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Check 1: Manifest must have required fields
|
||||
if (string.IsNullOrEmpty(manifest.VerdictDigest))
|
||||
{
|
||||
reasons.Add("Manifest is missing verdict digest");
|
||||
}
|
||||
|
||||
if (manifest.Inputs is null)
|
||||
{
|
||||
reasons.Add("Manifest is missing input digests");
|
||||
}
|
||||
|
||||
// Check 2: Time anchor must be present for deterministic replay
|
||||
if (manifest.TimeAnchor is null)
|
||||
{
|
||||
warnings.Add("No time anchor - replay may produce different results due to time-sensitive data");
|
||||
}
|
||||
|
||||
// Check 3: Verify replay support version
|
||||
if (!string.IsNullOrEmpty(manifest.ReplayVersion))
|
||||
{
|
||||
if (!IsSupportedReplayVersion(manifest.ReplayVersion))
|
||||
{
|
||||
reasons.Add($"Unsupported replay version: {manifest.ReplayVersion}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: Compare against current input state if provided
|
||||
if (currentInputState is not null && manifest.Inputs is not null)
|
||||
{
|
||||
var inputDivergence = DetectInputDivergence(manifest.Inputs, currentInputState);
|
||||
if (inputDivergence.HasDivergence)
|
||||
{
|
||||
warnings.AddRange(inputDivergence.Warnings);
|
||||
if (inputDivergence.IsCritical)
|
||||
{
|
||||
reasons.AddRange(inputDivergence.CriticalReasons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 5: Verify policy bundle compatibility
|
||||
if (!string.IsNullOrEmpty(manifest.PolicyVersion))
|
||||
{
|
||||
var policyCheck = CheckPolicyCompatibility(manifest.PolicyVersion);
|
||||
if (!policyCheck.IsCompatible)
|
||||
{
|
||||
reasons.Add(policyCheck.Reason!);
|
||||
}
|
||||
}
|
||||
|
||||
var isEligible = reasons.Count == 0;
|
||||
|
||||
return new ReplayEligibility
|
||||
{
|
||||
IsEligible = isEligible,
|
||||
Reasons = [.. reasons],
|
||||
Warnings = [.. warnings],
|
||||
ExpectedOutcome = isEligible
|
||||
? PredictOutcome(manifest, currentInputState)
|
||||
: null,
|
||||
ConfidenceScore = isEligible
|
||||
? ComputeConfidence(manifest, currentInputState, warnings)
|
||||
: 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicts the expected outcome of a replay based on input analysis.
|
||||
/// </summary>
|
||||
public ReplayOutcomePrediction PredictOutcome(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayInputState? currentInputState)
|
||||
{
|
||||
// Default to expecting a match if inputs haven't changed
|
||||
if (currentInputState is null)
|
||||
{
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Match,
|
||||
Confidence = 0.5,
|
||||
ExpectedDecision = manifest.Decision,
|
||||
Rationale = "Input state unknown - assuming match"
|
||||
};
|
||||
}
|
||||
|
||||
// Analyze input differences
|
||||
var divergence = DetectInputDivergence(manifest.Inputs!, currentInputState);
|
||||
|
||||
if (!divergence.HasDivergence)
|
||||
{
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Match,
|
||||
Confidence = 0.95,
|
||||
ExpectedDecision = manifest.Decision,
|
||||
Rationale = "All inputs match - expecting identical verdict"
|
||||
};
|
||||
}
|
||||
|
||||
// Predict based on divergence type
|
||||
if (divergence.FeedsChanged)
|
||||
{
|
||||
// Feeds changes most likely to cause verdict changes
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Drift,
|
||||
Confidence = 0.7,
|
||||
ExpectedDecision = null, // Unknown - depends on new advisories
|
||||
Rationale = "Vulnerability feeds have changed - verdict may differ",
|
||||
ExpectedDriftTypes = [DriftType.VerdictField, DriftType.Decision]
|
||||
};
|
||||
}
|
||||
|
||||
if (divergence.PolicyChanged)
|
||||
{
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Drift,
|
||||
Confidence = 0.6,
|
||||
ExpectedDecision = null,
|
||||
Rationale = "Policy rules have changed - decision may differ",
|
||||
ExpectedDriftTypes = [DriftType.Decision]
|
||||
};
|
||||
}
|
||||
|
||||
if (divergence.VexChanged)
|
||||
{
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Drift,
|
||||
Confidence = 0.5,
|
||||
ExpectedDecision = manifest.Decision, // VEX typically doesn't change decision
|
||||
Rationale = "VEX statements have changed - some findings may differ",
|
||||
ExpectedDriftTypes = [DriftType.VerdictField]
|
||||
};
|
||||
}
|
||||
|
||||
// SBOM changes are typically stable
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Match,
|
||||
Confidence = 0.8,
|
||||
ExpectedDecision = manifest.Decision,
|
||||
Rationale = "Minor input differences - expecting similar verdict"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two replay execution results and detects divergences.
|
||||
/// </summary>
|
||||
public ReplayDivergenceReport CompareDivergence(
|
||||
ReplayExecutionResult original,
|
||||
ReplayExecutionResult replayed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(original);
|
||||
ArgumentNullException.ThrowIfNull(replayed);
|
||||
|
||||
var divergences = new List<DivergenceItem>();
|
||||
|
||||
// Compare decisions
|
||||
if (original.OriginalDecision != replayed.ReplayedDecision)
|
||||
{
|
||||
divergences.Add(new DivergenceItem
|
||||
{
|
||||
Category = DivergenceCategory.Decision,
|
||||
Field = "decision",
|
||||
OriginalValue = original.OriginalDecision,
|
||||
ReplayedValue = replayed.ReplayedDecision,
|
||||
Severity = DivergenceSeverity.High,
|
||||
Explanation = "Policy decision changed between evaluations"
|
||||
});
|
||||
}
|
||||
|
||||
// Compare verdict digests
|
||||
if (original.OriginalVerdictDigest != replayed.ReplayedVerdictDigest)
|
||||
{
|
||||
divergences.Add(new DivergenceItem
|
||||
{
|
||||
Category = DivergenceCategory.VerdictHash,
|
||||
Field = "verdictDigest",
|
||||
OriginalValue = original.OriginalVerdictDigest,
|
||||
ReplayedValue = replayed.ReplayedVerdictDigest,
|
||||
Severity = DivergenceSeverity.Medium,
|
||||
Explanation = "Verdict content differs (may include new findings or different field values)"
|
||||
});
|
||||
}
|
||||
|
||||
// Include drift items from replay
|
||||
foreach (var drift in replayed.Drifts)
|
||||
{
|
||||
var severity = drift.Type switch
|
||||
{
|
||||
DriftType.Decision => DivergenceSeverity.High,
|
||||
DriftType.VerdictDigest => DivergenceSeverity.Medium,
|
||||
DriftType.InputDigest => DivergenceSeverity.Low,
|
||||
_ => DivergenceSeverity.Low
|
||||
};
|
||||
|
||||
divergences.Add(new DivergenceItem
|
||||
{
|
||||
Category = MapDriftTypeToCategory(drift.Type),
|
||||
Field = drift.Field ?? "unknown",
|
||||
OriginalValue = drift.Expected,
|
||||
ReplayedValue = drift.Actual,
|
||||
Severity = severity,
|
||||
Explanation = drift.Message ?? "Value mismatch detected"
|
||||
});
|
||||
}
|
||||
|
||||
return new ReplayDivergenceReport
|
||||
{
|
||||
HasDivergence = divergences.Count > 0,
|
||||
Divergences = [.. divergences],
|
||||
OverallSeverity = divergences.Count == 0
|
||||
? DivergenceSeverity.None
|
||||
: divergences.Max(d => d.Severity),
|
||||
Summary = GenerateDivergenceSummary(divergences)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsSupportedReplayVersion(string version)
|
||||
{
|
||||
// Support replay format versions 1.0 through 2.x
|
||||
return version.StartsWith("1.") || version.StartsWith("2.");
|
||||
}
|
||||
|
||||
private static InputDivergenceResult DetectInputDivergence(
|
||||
InputDigests expected,
|
||||
ReplayInputState current)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var criticalReasons = new List<string>();
|
||||
var hasDivergence = false;
|
||||
|
||||
bool feedsChanged = false, policyChanged = false, vexChanged = false;
|
||||
|
||||
if (current.FeedsDigest is not null && current.FeedsDigest != expected.FeedsDigest)
|
||||
{
|
||||
warnings.Add("Vulnerability feeds have been updated since original evaluation");
|
||||
hasDivergence = true;
|
||||
feedsChanged = true;
|
||||
}
|
||||
|
||||
if (current.PolicyDigest is not null && current.PolicyDigest != expected.PolicyDigest)
|
||||
{
|
||||
warnings.Add("Policy bundle has changed since original evaluation");
|
||||
hasDivergence = true;
|
||||
policyChanged = true;
|
||||
}
|
||||
|
||||
if (current.VexDigest is not null && current.VexDigest != expected.VexDigest)
|
||||
{
|
||||
warnings.Add("VEX statements have been updated since original evaluation");
|
||||
hasDivergence = true;
|
||||
vexChanged = true;
|
||||
}
|
||||
|
||||
if (current.SbomDigest is not null && current.SbomDigest != expected.SbomDigest)
|
||||
{
|
||||
criticalReasons.Add("SBOM differs from original - this is a different artifact");
|
||||
hasDivergence = true;
|
||||
}
|
||||
|
||||
return new InputDivergenceResult
|
||||
{
|
||||
HasDivergence = hasDivergence,
|
||||
IsCritical = criticalReasons.Count > 0,
|
||||
Warnings = warnings,
|
||||
CriticalReasons = criticalReasons,
|
||||
FeedsChanged = feedsChanged,
|
||||
PolicyChanged = policyChanged,
|
||||
VexChanged = vexChanged
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyCompatibility CheckPolicyCompatibility(string policyVersion)
|
||||
{
|
||||
// For now, accept all policy versions
|
||||
// In production, this would check against the policy engine capabilities
|
||||
return new PolicyCompatibility { IsCompatible = true };
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayInputState? currentInputState,
|
||||
List<string> warnings)
|
||||
{
|
||||
var confidence = 1.0;
|
||||
|
||||
// Reduce confidence for each warning
|
||||
confidence -= warnings.Count * 0.1;
|
||||
|
||||
// Reduce confidence if no time anchor
|
||||
if (manifest.TimeAnchor is null)
|
||||
{
|
||||
confidence -= 0.15;
|
||||
}
|
||||
|
||||
// Reduce confidence if input state is unknown
|
||||
if (currentInputState is null)
|
||||
{
|
||||
confidence -= 0.2;
|
||||
}
|
||||
|
||||
return Math.Max(0.1, confidence);
|
||||
}
|
||||
|
||||
private static DivergenceCategory MapDriftTypeToCategory(DriftType driftType)
|
||||
{
|
||||
return driftType switch
|
||||
{
|
||||
DriftType.Decision => DivergenceCategory.Decision,
|
||||
DriftType.VerdictDigest => DivergenceCategory.VerdictHash,
|
||||
DriftType.VerdictField => DivergenceCategory.VerdictField,
|
||||
DriftType.InputDigest => DivergenceCategory.Input,
|
||||
_ => DivergenceCategory.Other
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateDivergenceSummary(List<DivergenceItem> divergences)
|
||||
{
|
||||
if (divergences.Count == 0)
|
||||
{
|
||||
return "Replay matched original verdict exactly.";
|
||||
}
|
||||
|
||||
var hasDecisionChange = divergences.Any(d => d.Category == DivergenceCategory.Decision);
|
||||
var hasVerdictChange = divergences.Any(d => d.Category == DivergenceCategory.VerdictHash);
|
||||
var hasInputChange = divergences.Any(d => d.Category == DivergenceCategory.Input);
|
||||
|
||||
if (hasDecisionChange)
|
||||
{
|
||||
return "Replay produced a different policy decision.";
|
||||
}
|
||||
|
||||
if (hasVerdictChange)
|
||||
{
|
||||
return "Replay verdict differs in content but decision is the same.";
|
||||
}
|
||||
|
||||
if (hasInputChange)
|
||||
{
|
||||
return "Input digests differ but verdict is unchanged.";
|
||||
}
|
||||
|
||||
return $"Replay detected {divergences.Count} divergence(s).";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for verdict replay predicate.
|
||||
/// </summary>
|
||||
public interface IVerdictReplayPredicate
|
||||
{
|
||||
ReplayEligibility Evaluate(AuditBundleManifest manifest, ReplayInputState? currentInputState = null);
|
||||
ReplayOutcomePrediction PredictOutcome(AuditBundleManifest manifest, ReplayInputState? currentInputState);
|
||||
ReplayDivergenceReport CompareDivergence(ReplayExecutionResult original, ReplayExecutionResult replayed);
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating replay eligibility.
|
||||
/// </summary>
|
||||
public sealed record ReplayEligibility
|
||||
{
|
||||
public bool IsEligible { get; init; }
|
||||
public IReadOnlyList<string> Reasons { get; init; } = [];
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
public ReplayOutcomePrediction? ExpectedOutcome { get; init; }
|
||||
public double ConfidenceScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prediction of replay outcome.
|
||||
/// </summary>
|
||||
public sealed record ReplayOutcomePrediction
|
||||
{
|
||||
public ReplayStatus ExpectedStatus { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public string? ExpectedDecision { get; init; }
|
||||
public string? Rationale { get; init; }
|
||||
public IReadOnlyList<DriftType>? ExpectedDriftTypes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current state of replay inputs for comparison.
|
||||
/// </summary>
|
||||
public sealed record ReplayInputState
|
||||
{
|
||||
public string? SbomDigest { get; init; }
|
||||
public string? FeedsDigest { get; init; }
|
||||
public string? PolicyDigest { get; init; }
|
||||
public string? VexDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report of divergences between original and replayed evaluations.
|
||||
/// </summary>
|
||||
public sealed record ReplayDivergenceReport
|
||||
{
|
||||
public bool HasDivergence { get; init; }
|
||||
public IReadOnlyList<DivergenceItem> Divergences { get; init; } = [];
|
||||
public DivergenceSeverity OverallSeverity { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual divergence item.
|
||||
/// </summary>
|
||||
public sealed record DivergenceItem
|
||||
{
|
||||
public DivergenceCategory Category { get; init; }
|
||||
public required string Field { get; init; }
|
||||
public string? OriginalValue { get; init; }
|
||||
public string? ReplayedValue { get; init; }
|
||||
public DivergenceSeverity Severity { get; init; }
|
||||
public string? Explanation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Category of divergence.
|
||||
/// </summary>
|
||||
public enum DivergenceCategory
|
||||
{
|
||||
Decision,
|
||||
VerdictHash,
|
||||
VerdictField,
|
||||
Input,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity of divergence.
|
||||
/// </summary>
|
||||
public enum DivergenceSeverity
|
||||
{
|
||||
None,
|
||||
Low,
|
||||
Medium,
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of input divergence detection.
|
||||
/// </summary>
|
||||
internal sealed record InputDivergenceResult
|
||||
{
|
||||
public bool HasDivergence { get; init; }
|
||||
public bool IsCritical { get; init; }
|
||||
public List<string> Warnings { get; init; } = [];
|
||||
public List<string> CriticalReasons { get; init; } = [];
|
||||
public bool FeedsChanged { get; init; }
|
||||
public bool PolicyChanged { get; init; }
|
||||
public bool VexChanged { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy compatibility check.
|
||||
/// </summary>
|
||||
internal sealed record PolicyCompatibility
|
||||
{
|
||||
public bool IsCompatible { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user