// ----------------------------------------------------------------------------- // PolicyDecisionAttestationService.cs // Sprint: SPRINT_3801_0001_0001_policy_decision_attestation // Description: Implementation of policy decision attestation service. // ----------------------------------------------------------------------------- using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; namespace StellaOps.Scanner.WebService.Services; /// /// Implementation of the policy decision attestation service. /// /// /// Creates in-toto statements for policy decisions. The actual DSSE signing /// is deferred to the Attestor module when available. /// public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestationService { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = false }; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly PolicyDecisionAttestationOptions _options; // In-memory store for attestations (production would use persistent storage) private readonly ConcurrentDictionary _attestations = new(); public PolicyDecisionAttestationService( ILogger logger, IOptions? options = null, TimeProvider? timeProvider = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; _options = options?.Value ?? new PolicyDecisionAttestationOptions(); } /// public Task CreateAttestationAsync( PolicyDecisionInput input, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(input); ArgumentException.ThrowIfNullOrWhiteSpace(input.FindingId); ArgumentException.ThrowIfNullOrWhiteSpace(input.Cve); ArgumentException.ThrowIfNullOrWhiteSpace(input.ComponentPurl); try { var now = _timeProvider.GetUtcNow(); var ttl = input.DecisionTtl ?? TimeSpan.FromDays(_options.DefaultDecisionTtlDays); var expiresAt = now.Add(ttl); // Build the statement var statement = BuildStatement(input, now, expiresAt); // Compute content-addressed ID var attestationId = ComputeAttestationId(statement); // Store the attestation var key = BuildKey(input.ScanId, input.FindingId); var result = PolicyDecisionAttestationResult.Succeeded( statement, attestationId, dsseEnvelope: null // Signing deferred to Attestor module ); _attestations[key] = result; _logger.LogInformation( "Created policy decision attestation for {FindingId}: {Decision} (score={Score}, attestation={AttestationId})", input.FindingId, input.Decision, input.Reasoning.FinalScore, attestationId); return Task.FromResult(result); } catch (Exception ex) { _logger.LogError(ex, "Failed to create policy decision attestation for {FindingId}", input.FindingId); return Task.FromResult(PolicyDecisionAttestationResult.Failed(ex.Message)); } } /// public Task GetAttestationAsync( ScanId scanId, string findingId, CancellationToken cancellationToken = default) { var key = BuildKey(scanId, findingId); if (_attestations.TryGetValue(key, out var result)) { return Task.FromResult(result); } return Task.FromResult(null); } private PolicyDecisionStatement BuildStatement( PolicyDecisionInput input, DateTimeOffset evaluatedAt, DateTimeOffset expiresAt) { // Build subjects - the scan and finding are the subjects of this attestation var subjects = new List { new() { Name = $"scan:{input.ScanId.Value}", Digest = new Dictionary { ["sha256"] = ComputeSha256(input.ScanId.Value) } }, new() { Name = $"finding:{input.FindingId}", Digest = new Dictionary { ["sha256"] = ComputeSha256(input.FindingId) } } }; // Build predicate var predicate = new PolicyDecisionPredicate { FindingId = input.FindingId, Cve = input.Cve, ComponentPurl = input.ComponentPurl, Decision = input.Decision, Reasoning = input.Reasoning, EvidenceRefs = input.EvidenceRefs, EvaluatedAt = evaluatedAt, ExpiresAt = expiresAt, PolicyVersion = input.PolicyVersion, PolicyHash = input.PolicyHash }; return new PolicyDecisionStatement { Subject = subjects, Predicate = predicate }; } private static string ComputeAttestationId(PolicyDecisionStatement statement) { var json = JsonSerializer.Serialize(statement, JsonOptions); var hash = ComputeSha256(json); return $"sha256:{hash}"; } private static string ComputeSha256(string input) { var bytes = Encoding.UTF8.GetBytes(input); var hashBytes = SHA256.HashData(bytes); return Convert.ToHexStringLower(hashBytes); } private static string BuildKey(ScanId scanId, string findingId) => $"{scanId.Value}:{findingId}"; } /// /// Configuration options for policy decision attestations. /// public sealed class PolicyDecisionAttestationOptions { /// /// Default TTL for policy decisions in days. /// public int DefaultDecisionTtlDays { get; set; } = 30; /// /// Whether to enable DSSE signing when Attestor is available. /// public bool EnableSigning { get; set; } = true; /// /// Key profile to use for signing attestations. /// public string SigningKeyProfile { get; set; } = "Reasoning"; }