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,318 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HumanApprovalAttestationService.cs
|
||||
// Sprint: SPRINT_3801_0001_0004_human_approval_attestation (APPROVE-003)
|
||||
// Description: Creates DSSE attestations for human approval decisions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
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>
|
||||
/// Creates DSSE attestations for human approval decisions.
|
||||
/// </summary>
|
||||
public sealed class HumanApprovalAttestationService : IHumanApprovalAttestationService
|
||||
{
|
||||
private readonly ILogger<HumanApprovalAttestationService> _logger;
|
||||
private readonly HumanApprovalAttestationOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory attestation store. In production, this would be backed by a database.
|
||||
/// Key format: "{scanId}:{findingId}"
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, StoredApproval> _attestations = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="HumanApprovalAttestationService"/>.
|
||||
/// </summary>
|
||||
public HumanApprovalAttestationService(
|
||||
ILogger<HumanApprovalAttestationService> logger,
|
||||
IOptions<HumanApprovalAttestationOptions> options,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HumanApprovalAttestationResult> CreateAttestationAsync(
|
||||
HumanApprovalAttestationInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.FindingId))
|
||||
{
|
||||
throw new ArgumentException("FindingId is required", nameof(input));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.ApproverUserId))
|
||||
{
|
||||
throw new ArgumentException("ApproverUserId is required", nameof(input));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Justification))
|
||||
{
|
||||
throw new ArgumentException("Justification is required", nameof(input));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Creating human approval attestation for finding {FindingId}, decision {Decision}",
|
||||
input.FindingId,
|
||||
input.Decision);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var ttl = input.ApprovalTtl ?? TimeSpan.FromDays(_options.DefaultApprovalTtlDays);
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var approvalId = $"approval-{Guid.NewGuid():N}";
|
||||
|
||||
var statement = BuildStatement(input, approvalId, now, expiresAt);
|
||||
var attestationId = ComputeAttestationId(statement);
|
||||
|
||||
// Store the attestation
|
||||
var key = BuildKey(input.ScanId, input.FindingId);
|
||||
var storedApproval = new StoredApproval
|
||||
{
|
||||
Result = HumanApprovalAttestationResult.Succeeded(statement, attestationId),
|
||||
IsRevoked = false,
|
||||
RevokedAt = null,
|
||||
RevokedBy = null,
|
||||
RevocationReason = null
|
||||
};
|
||||
|
||||
_attestations.AddOrUpdate(key, storedApproval, (_, _) => storedApproval);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created human approval attestation {AttestationId} for finding {FindingId}, expires {ExpiresAt}",
|
||||
attestationId,
|
||||
input.FindingId,
|
||||
expiresAt);
|
||||
|
||||
return Task.FromResult(storedApproval.Result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HumanApprovalAttestationResult?> GetAttestationAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scanId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return Task.FromResult<HumanApprovalAttestationResult?>(null);
|
||||
}
|
||||
|
||||
var key = BuildKey(scanId, findingId);
|
||||
|
||||
if (_attestations.TryGetValue(key, out var stored))
|
||||
{
|
||||
// Check if expired
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (stored.Result.Statement?.Predicate.ExpiresAt < now)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Approval attestation for finding {FindingId} has expired",
|
||||
findingId);
|
||||
return Task.FromResult<HumanApprovalAttestationResult?>(null);
|
||||
}
|
||||
|
||||
if (stored.IsRevoked)
|
||||
{
|
||||
return Task.FromResult<HumanApprovalAttestationResult?>(
|
||||
stored.Result with { IsRevoked = true });
|
||||
}
|
||||
|
||||
return Task.FromResult<HumanApprovalAttestationResult?>(stored.Result);
|
||||
}
|
||||
|
||||
return Task.FromResult<HumanApprovalAttestationResult?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<HumanApprovalAttestationResult>> GetApprovalsByScanAsync(
|
||||
ScanId scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scanId);
|
||||
|
||||
var prefix = $"{scanId}:";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var results = _attestations
|
||||
.Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Where(kvp => !kvp.Value.IsRevoked)
|
||||
.Where(kvp => kvp.Value.Result.Statement?.Predicate.ExpiresAt >= now)
|
||||
.Select(kvp => kvp.Value.Result)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<HumanApprovalAttestationResult>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RevokeApprovalAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
string revokedBy,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scanId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(revokedBy))
|
||||
{
|
||||
throw new ArgumentException("revokedBy is required", nameof(revokedBy));
|
||||
}
|
||||
|
||||
var key = BuildKey(scanId, findingId);
|
||||
|
||||
if (_attestations.TryGetValue(key, out var stored))
|
||||
{
|
||||
var revoked = stored with
|
||||
{
|
||||
IsRevoked = true,
|
||||
RevokedAt = _timeProvider.GetUtcNow(),
|
||||
RevokedBy = revokedBy,
|
||||
RevocationReason = reason
|
||||
};
|
||||
|
||||
_attestations.TryUpdate(key, revoked, stored);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Revoked approval attestation for finding {FindingId} by {RevokedBy}: {Reason}",
|
||||
findingId,
|
||||
revokedBy,
|
||||
reason);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
private HumanApprovalStatement BuildStatement(
|
||||
HumanApprovalAttestationInput input,
|
||||
string approvalId,
|
||||
DateTimeOffset approvedAt,
|
||||
DateTimeOffset expiresAt)
|
||||
{
|
||||
var scanDigest = ComputeSha256(input.ScanId.ToString());
|
||||
var findingDigest = ComputeSha256(input.FindingId);
|
||||
|
||||
return new HumanApprovalStatement
|
||||
{
|
||||
Subject = new List<HumanApprovalSubject>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = $"scan:{input.ScanId}",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = scanDigest }
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = $"finding:{input.FindingId}",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = findingDigest }
|
||||
}
|
||||
},
|
||||
Predicate = new HumanApprovalPredicate
|
||||
{
|
||||
ApprovalId = approvalId,
|
||||
FindingId = input.FindingId,
|
||||
Decision = input.Decision,
|
||||
Approver = new ApproverInfo
|
||||
{
|
||||
UserId = input.ApproverUserId,
|
||||
DisplayName = input.ApproverDisplayName,
|
||||
Role = input.ApproverRole
|
||||
},
|
||||
Justification = input.Justification,
|
||||
ApprovedAt = approvedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
PolicyDecisionRef = input.PolicyDecisionRef,
|
||||
Restrictions = input.Restrictions,
|
||||
Supersedes = input.Supersedes,
|
||||
Metadata = input.Metadata
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeAttestationId(HumanApprovalStatement statement)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(statement);
|
||||
return ComputeSha256(json);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string BuildKey(ScanId scanId, string findingId)
|
||||
=> $"{scanId}:{findingId}";
|
||||
|
||||
/// <summary>
|
||||
/// Internal storage for approval attestations with revocation tracking.
|
||||
/// </summary>
|
||||
private sealed record StoredApproval
|
||||
{
|
||||
public required HumanApprovalAttestationResult Result { get; init; }
|
||||
public bool IsRevoked { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? RevokedBy { get; init; }
|
||||
public string? RevocationReason { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for human approval attestation service.
|
||||
/// </summary>
|
||||
public sealed class HumanApprovalAttestationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default TTL for approvals in days (default: 30).
|
||||
/// </summary>
|
||||
public int DefaultApprovalTtlDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable DSSE signing.
|
||||
/// </summary>
|
||||
public bool EnableSigning { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum justification length required.
|
||||
/// </summary>
|
||||
public int MinJustificationLength { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Roles authorized to approve high-severity findings.
|
||||
/// </summary>
|
||||
public IList<string> HighSeverityApproverRoles { get; set; } = new List<string>
|
||||
{
|
||||
"security_lead",
|
||||
"ciso",
|
||||
"security_architect"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user