// -----------------------------------------------------------------------------
// 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";
}