- 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.
319 lines
11 KiB
C#
319 lines
11 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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"
|
|
};
|
|
}
|