feat: Add VEX Status Chip component and integration tests for reachability drift detection
- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of the policy decision attestation service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creates in-toto statements for policy decisions. The actual DSSE signing
|
||||
/// is deferred to the Attestor module when available.
|
||||
/// </remarks>
|
||||
public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ILogger<PolicyDecisionAttestationService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly PolicyDecisionAttestationOptions _options;
|
||||
|
||||
// In-memory store for attestations (production would use persistent storage)
|
||||
private readonly ConcurrentDictionary<string, PolicyDecisionAttestationResult> _attestations = new();
|
||||
|
||||
public PolicyDecisionAttestationService(
|
||||
ILogger<PolicyDecisionAttestationService> logger,
|
||||
IOptions<PolicyDecisionAttestationOptions>? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_options = options?.Value ?? new PolicyDecisionAttestationOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PolicyDecisionAttestationResult> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PolicyDecisionAttestationResult?> GetAttestationAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(scanId, findingId);
|
||||
if (_attestations.TryGetValue(key, out var result))
|
||||
{
|
||||
return Task.FromResult<PolicyDecisionAttestationResult?>(result);
|
||||
}
|
||||
|
||||
return Task.FromResult<PolicyDecisionAttestationResult?>(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<PolicyDecisionSubject>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = $"scan:{input.ScanId.Value}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ComputeSha256(input.ScanId.Value)
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = $"finding:{input.FindingId}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["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}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for policy decision attestations.
|
||||
/// </summary>
|
||||
public sealed class PolicyDecisionAttestationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default TTL for policy decisions in days.
|
||||
/// </summary>
|
||||
public int DefaultDecisionTtlDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable DSSE signing when Attestor is available.
|
||||
/// </summary>
|
||||
public bool EnableSigning { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Key profile to use for signing attestations.
|
||||
/// </summary>
|
||||
public string SigningKeyProfile { get; set; } = "Reasoning";
|
||||
}
|
||||
Reference in New Issue
Block a user