// ----------------------------------------------------------------------------- // DriftAttestationService.cs // Sprint: SPRINT_3600_0004_0001_ui_evidence_chain // Task: UI-016 // Description: Service for creating signed reachability drift attestations. // ----------------------------------------------------------------------------- using System.Collections.Immutable; using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Encodings.Web; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Attestor.ProofChain.Predicates; using StellaOps.Signer.Core; namespace StellaOps.Scanner.ReachabilityDrift.Attestation; /// /// Default implementation of . /// Creates stellaops.dev/predicates/reachability-drift@v1 attestations wrapped in DSSE envelopes. /// public sealed class DriftAttestationService : IDriftAttestationService { private static readonly JsonSerializerOptions CanonicalJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private readonly IDriftSignerClient? _signerClient; private readonly IOptionsMonitor _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public DriftAttestationService( IDriftSignerClient? signerClient, IOptionsMonitor options, TimeProvider timeProvider, ILogger logger) { _signerClient = signerClient; _options = options ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task CreateAttestationAsync( DriftAttestationRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); using var activity = Activity.Current?.Source.StartActivity( "reachability_drift.attest", ActivityKind.Internal); activity?.SetTag("tenant", request.TenantId); activity?.SetTag("base_scan", request.DriftResult.BaseScanId); activity?.SetTag("head_scan", request.DriftResult.HeadScanId); var options = _options.CurrentValue; if (!options.Enabled) { _logger.LogDebug("Drift attestation is disabled"); return new DriftAttestationResult { Success = false, Error = "Attestation creation is disabled" }; } try { // Build the predicate var predicate = BuildPredicate(request); // Build the in-toto statement var statement = BuildStatement(request, predicate); var statementJson = SerializeCanonical(statement); var payloadBase64 = Convert.ToBase64String(statementJson); // Sign the payload DriftDsseSignature signature; string? keyId; if (_signerClient is not null && options.UseSignerService) { var signResult = await _signerClient.SignAsync( new DriftSignerRequest { PayloadType = ReachabilityDriftPredicate.PredicateType, PayloadBase64 = payloadBase64, KeyId = request.KeyId ?? options.DefaultKeyId, TenantId = request.TenantId }, cancellationToken).ConfigureAwait(false); if (!signResult.Success) { _logger.LogWarning("Failed to sign drift attestation: {Error}", signResult.Error); return new DriftAttestationResult { Success = false, Error = signResult.Error ?? "Signing failed" }; } keyId = signResult.KeyId; signature = new DriftDsseSignature { KeyId = signResult.KeyId ?? "unknown", Sig = signResult.Signature! }; } else { // Create locally-signed envelope (dev/test mode) keyId = "local-dev-key"; signature = SignLocally(statementJson); _logger.LogDebug("Created locally-signed attestation (signer service not available)"); } var envelope = new DriftDsseEnvelope { PayloadType = "application/vnd.in-toto+json", Payload = payloadBase64, Signatures = [signature] }; var envelopeJson = JsonSerializer.Serialize(envelope, CanonicalJsonOptions); var envelopeDigestHex = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson))).ToLowerInvariant(); var attestationDigest = $"sha256:{envelopeDigestHex}"; _logger.LogInformation( "Created drift attestation for scans {BaseScan} → {HeadScan}. " + "Newly reachable: {NewlyReachable}, Newly unreachable: {NewlyUnreachable}. Digest: {Digest}", request.DriftResult.BaseScanId, request.DriftResult.HeadScanId, request.DriftResult.NewlyReachable.Length, request.DriftResult.NewlyUnreachable.Length, attestationDigest); return new DriftAttestationResult { Success = true, AttestationDigest = attestationDigest, EnvelopeJson = envelopeJson, KeyId = keyId, CreatedAt = _timeProvider.GetUtcNow() }; } catch (Exception ex) { _logger.LogError(ex, "Failed to create drift attestation"); activity?.SetStatus(ActivityStatusCode.Error, ex.Message); return new DriftAttestationResult { Success = false, Error = ex.Message }; } } private ReachabilityDriftPredicate BuildPredicate(DriftAttestationRequest request) { var drift = request.DriftResult; var now = _timeProvider.GetUtcNow(); return new ReachabilityDriftPredicate { BaseImage = new DriftImageReference { Name = request.BaseImage.Name, Digest = request.BaseImage.Digest, Tag = request.BaseImage.Tag }, TargetImage = new DriftImageReference { Name = request.TargetImage.Name, Digest = request.TargetImage.Digest, Tag = request.TargetImage.Tag }, BaseScanId = drift.BaseScanId, HeadScanId = drift.HeadScanId, Drift = new DriftPredicateSummary { NewlyReachableCount = drift.NewlyReachable.Length, NewlyUnreachableCount = drift.NewlyUnreachable.Length, NewlyReachable = drift.NewlyReachable .Select(s => MapSinkToSummary(s)) .ToImmutableArray(), NewlyUnreachable = drift.NewlyUnreachable .Select(s => MapSinkToSummary(s)) .ToImmutableArray() }, Analysis = new DriftAnalysisMetadata { AnalyzedAt = now, Scanner = new DriftScannerInfo { Name = "StellaOps.Scanner", Version = GetScannerVersion(), Ruleset = _options.CurrentValue.SinkRuleset }, BaseGraphDigest = request.BaseGraphDigest, HeadGraphDigest = request.HeadGraphDigest, CodeChangesDigest = request.CodeChangesDigest } }; } private static DriftedSinkPredicateSummary MapSinkToSummary(DriftedSink sink) { return new DriftedSinkPredicateSummary { SinkNodeId = sink.SinkNodeId, Symbol = sink.Symbol, SinkCategory = sink.SinkCategory.ToString(), CauseKind = sink.Cause.Kind.ToString(), CauseDescription = sink.Cause.Description, AssociatedCves = sink.AssociatedVulns .Select(v => v.CveId) .Where(cve => !string.IsNullOrEmpty(cve)) .ToImmutableArray()!, PathHash = ComputePathHash(sink.Path) }; } private static string ComputePathHash(CompressedPath path) { // Create a deterministic representation of the path var pathData = new StringBuilder(); pathData.Append(path.Entrypoint.NodeId); pathData.Append(':'); foreach (var node in path.KeyNodes) { pathData.Append(node.NodeId); pathData.Append(':'); } pathData.Append(path.Sink.NodeId); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(pathData.ToString())); return Convert.ToHexString(hash).ToLowerInvariant()[..16]; // First 64 bits } private DriftInTotoStatement BuildStatement( DriftAttestationRequest request, ReachabilityDriftPredicate predicate) { return new DriftInTotoStatement { Type = "https://in-toto.io/Statement/v1", Subject = [ new DriftSubject { Name = request.TargetImage.Name, Digest = new Dictionary { ["sha256"] = request.TargetImage.Digest.Replace("sha256:", "") } } ], PredicateType = ReachabilityDriftPredicate.PredicateType, Predicate = predicate }; } private static byte[] SerializeCanonical(T value) { return JsonSerializer.SerializeToUtf8Bytes(value, CanonicalJsonOptions); } private static DriftDsseSignature SignLocally(byte[] payload) { // Local/dev signing: create a placeholder signature // In production, this would use a real key var paeString = $"DSSEv1 {payload.Length} application/vnd.in-toto+json {payload.Length} "; var paeBytes = Encoding.UTF8.GetBytes(paeString).Concat(payload).ToArray(); var hash = SHA256.HashData(paeBytes); return new DriftDsseSignature { KeyId = "local-dev-key", Sig = Convert.ToBase64String(hash) }; } private static string GetScannerVersion() { var assembly = typeof(DriftAttestationService).Assembly; var version = assembly.GetName().Version; return version?.ToString() ?? "0.0.0"; } } /// /// In-toto statement for drift attestation. /// internal sealed record DriftInTotoStatement { [JsonPropertyName("_type")] public required string Type { get; init; } [JsonPropertyName("subject")] public required IReadOnlyList Subject { get; init; } [JsonPropertyName("predicateType")] public required string PredicateType { get; init; } [JsonPropertyName("predicate")] public required ReachabilityDriftPredicate Predicate { get; init; } } /// /// Subject in an in-toto statement. /// internal sealed record DriftSubject { [JsonPropertyName("name")] public required string Name { get; init; } [JsonPropertyName("digest")] public required IReadOnlyDictionary Digest { get; init; } } /// /// DSSE envelope for drift attestation. /// internal sealed record DriftDsseEnvelope { [JsonPropertyName("payloadType")] public required string PayloadType { get; init; } [JsonPropertyName("payload")] public required string Payload { get; init; } [JsonPropertyName("signatures")] public required IReadOnlyList Signatures { get; init; } } /// /// Signature in a DSSE envelope. /// internal sealed record DriftDsseSignature { [JsonPropertyName("keyid")] public required string KeyId { get; init; } [JsonPropertyName("sig")] public required string Sig { get; init; } }