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:
@@ -7,4 +7,6 @@ internal static class ProblemTypes
|
||||
public const string NotFound = "https://stellaops.org/problems/not-found";
|
||||
public const string InternalError = "https://stellaops.org/problems/internal-error";
|
||||
public const string RateLimited = "https://stellaops.org/problems/rate-limit";
|
||||
public const string Authentication = "https://stellaops.org/problems/authentication";
|
||||
public const string Internal = "https://stellaops.org/problems/internal";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationChain.cs
|
||||
// Sprint: SPRINT_3801_0001_0003_chain_verification (CHAIN-002)
|
||||
// Description: Models for attestation chain verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chain of attestations for a finding.
|
||||
/// </summary>
|
||||
public sealed record AttestationChain
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed chain identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("chain_id")]
|
||||
public required string ChainId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The scan ID this chain belongs to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scan_id")]
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The finding ID (e.g., CVE identifier) this chain is for.
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The root digest (typically the scan/image digest).
|
||||
/// </summary>
|
||||
[JsonPropertyName("root_digest")]
|
||||
public required string RootDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestations in this chain, ordered from root to leaf.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestations")]
|
||||
public required ImmutableList<ChainAttestation> Attestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the entire chain is verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified")]
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the chain was verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified_at")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The chain status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("chain_status")]
|
||||
public required ChainStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the earliest attestation in the chain expires.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single attestation in the chain.
|
||||
/// </summary>
|
||||
public sealed record ChainAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of attestation (e.g., "richgraph", "policy_decision", "human_approval").
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required AttestationType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestation_id")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the attestation was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the attestation expires.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the attestation signature verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified")]
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The verification status of this attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verification_status")]
|
||||
public required AttestationVerificationStatus VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject digest this attestation covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject_digest")]
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The predicate type URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate_type")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional error message if verification failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The type of attestation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttestationType
|
||||
{
|
||||
/// <summary>
|
||||
/// RichGraph computation attestation.
|
||||
/// </summary>
|
||||
RichGraph,
|
||||
|
||||
/// <summary>
|
||||
/// Policy decision attestation.
|
||||
/// </summary>
|
||||
PolicyDecision,
|
||||
|
||||
/// <summary>
|
||||
/// Human approval attestation.
|
||||
/// </summary>
|
||||
HumanApproval,
|
||||
|
||||
/// <summary>
|
||||
/// SBOM generation attestation.
|
||||
/// </summary>
|
||||
Sbom,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability scan attestation.
|
||||
/// </summary>
|
||||
VulnerabilityScan
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The verification status of an attestation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttestationVerificationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Verification succeeded.
|
||||
/// </summary>
|
||||
Valid,
|
||||
|
||||
/// <summary>
|
||||
/// Attestation has expired.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification failed.
|
||||
/// </summary>
|
||||
InvalidSignature,
|
||||
|
||||
/// <summary>
|
||||
/// Attestation not found.
|
||||
/// </summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Chain link broken (digest mismatch).
|
||||
/// </summary>
|
||||
ChainBroken,
|
||||
|
||||
/// <summary>
|
||||
/// Attestation has been revoked.
|
||||
/// </summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>
|
||||
/// Verification pending.
|
||||
/// </summary>
|
||||
Pending
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The overall status of the attestation chain.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ChainStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// All attestations present and valid.
|
||||
/// </summary>
|
||||
Complete,
|
||||
|
||||
/// <summary>
|
||||
/// Some attestations missing but core valid.
|
||||
/// </summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>
|
||||
/// One or more attestations past TTL.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification failed.
|
||||
/// </summary>
|
||||
Invalid,
|
||||
|
||||
/// <summary>
|
||||
/// Chain link missing or digest mismatch.
|
||||
/// </summary>
|
||||
Broken,
|
||||
|
||||
/// <summary>
|
||||
/// Chain is empty (no attestations).
|
||||
/// </summary>
|
||||
Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for chain verification.
|
||||
/// </summary>
|
||||
public sealed record ChainVerificationInput
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan ID to verify chain for.
|
||||
/// </summary>
|
||||
public required Domain.ScanId ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The finding ID to verify chain for.
|
||||
/// </summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The expected root digest.
|
||||
/// </summary>
|
||||
public required string RootDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: specific attestation types to verify.
|
||||
/// If null, verifies all available attestations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AttestationType>? RequiredTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require human approval in the chain.
|
||||
/// </summary>
|
||||
public bool RequireHumanApproval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Grace period for expired attestations (default: 0).
|
||||
/// </summary>
|
||||
public TimeSpan ExpirationGracePeriod { get; init; } = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of chain verification.
|
||||
/// </summary>
|
||||
public sealed record ChainVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether verification succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The verified chain.
|
||||
/// </summary>
|
||||
public AttestationChain? Chain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification results per attestation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AttestationVerificationDetail>? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static ChainVerificationResult Succeeded(
|
||||
AttestationChain chain,
|
||||
IReadOnlyList<AttestationVerificationDetail>? details = null)
|
||||
=> new()
|
||||
{
|
||||
Success = true,
|
||||
Chain = chain,
|
||||
Details = details
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static ChainVerificationResult Failed(string error, AttestationChain? chain = null)
|
||||
=> new()
|
||||
{
|
||||
Success = false,
|
||||
Chain = chain,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification result for a single attestation.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerificationDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// The attestation type.
|
||||
/// </summary>
|
||||
public required AttestationType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation ID.
|
||||
/// </summary>
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The verification status.
|
||||
/// </summary>
|
||||
public required AttestationVerificationStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the attestation was verified successfully.
|
||||
/// </summary>
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time taken for verification.
|
||||
/// </summary>
|
||||
public TimeSpan? VerificationTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -77,6 +77,12 @@ public sealed record FindingEvidenceResponse
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the evidence is stale (expired or near-expiry).
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_stale")]
|
||||
public bool IsStale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// References to DSSE/in-toto attestations backing this evidence.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HumanApprovalStatement.cs
|
||||
// Sprint: SPRINT_3801_0001_0004_human_approval_attestation (APPROVE-002)
|
||||
// Description: In-toto statement format for human approval attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for human approval attestations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Human approval attestations record decisions made by authorized personnel
|
||||
/// to accept, defer, reject, suppress, or escalate security findings.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Default TTL is 30 days to force periodic re-review of risk acceptances.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record HumanApprovalStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// The in-toto statement type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type => "https://in-toto.io/Statement/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The predicate type URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType => "stella.ops/human-approval@v1";
|
||||
|
||||
/// <summary>
|
||||
/// The subjects this attestation covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required IList<HumanApprovalSubject> Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The human approval predicate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required HumanApprovalPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject reference for human approval attestation.
|
||||
/// </summary>
|
||||
public sealed record HumanApprovalSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// The subject name (e.g., "scan:12345" or "finding:CVE-2024-12345").
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject digest(s).
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required IDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The human approval predicate data.
|
||||
/// </summary>
|
||||
public sealed record HumanApprovalPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema")]
|
||||
public string Schema => "human-approval-v1";
|
||||
|
||||
/// <summary>
|
||||
/// Unique approval identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approval_id")]
|
||||
public required string ApprovalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The finding ID (e.g., CVE identifier).
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approval decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required ApprovalDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about the approver.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approver")]
|
||||
public required ApproverInfo Approver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the approval was made.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approved_at")]
|
||||
public required DateTimeOffset ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the approval expires.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy decision this approval is for.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_decision_ref")]
|
||||
public string? PolicyDecisionRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional restrictions on the approval scope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("restrictions")]
|
||||
public ApprovalRestrictions? Restrictions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional prior approval being superseded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("supersedes")]
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the person who made the approval.
|
||||
/// </summary>
|
||||
public sealed record ApproverInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The approver's user identifier (e.g., email).
|
||||
/// </summary>
|
||||
[JsonPropertyName("user_id")]
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's display name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("display_name")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's role in the organization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("role")]
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional delegation chain (if approving on behalf of someone else).
|
||||
/// </summary>
|
||||
[JsonPropertyName("delegated_from")]
|
||||
public string? DelegatedFrom { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restrictions on the approval scope.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRestrictions
|
||||
{
|
||||
/// <summary>
|
||||
/// Environments where the approval applies (e.g., "production", "staging").
|
||||
/// </summary>
|
||||
[JsonPropertyName("environments")]
|
||||
public IList<string>? Environments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of affected instances.
|
||||
/// </summary>
|
||||
[JsonPropertyName("max_instances")]
|
||||
public int? MaxInstances { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Namespaces where the approval applies.
|
||||
/// </summary>
|
||||
[JsonPropertyName("namespaces")]
|
||||
public IList<string>? Namespaces { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific images/artifacts the approval applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IList<string>? Artifacts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom conditions that must be met.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conditions")]
|
||||
public IDictionary<string, string>? Conditions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The approval decision type.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ApprovalDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Risk accepted with justification.
|
||||
/// </summary>
|
||||
AcceptRisk,
|
||||
|
||||
/// <summary>
|
||||
/// Decision deferred, requires re-review.
|
||||
/// </summary>
|
||||
Defer,
|
||||
|
||||
/// <summary>
|
||||
/// Finding must be remediated.
|
||||
/// </summary>
|
||||
Reject,
|
||||
|
||||
/// <summary>
|
||||
/// Finding suppressed (false positive).
|
||||
/// </summary>
|
||||
Suppress,
|
||||
|
||||
/// <summary>
|
||||
/// Escalated to higher authority.
|
||||
/// </summary>
|
||||
Escalate
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PolicyDecisionStatement.cs
|
||||
// Sprint: SPRINT_3801_0001_0001_policy_decision_attestation
|
||||
// Description: In-toto statement for policy decision attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for policy evaluation decisions.
|
||||
/// Predicate type: stella.ops/policy-decision@v1
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This statement attests that a policy decision was made for a finding
|
||||
/// based on the evidence available at evaluation time.
|
||||
/// </remarks>
|
||||
public sealed record PolicyDecisionStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// The statement type, always "https://in-toto.io/Statement/v1".
|
||||
/// </summary>
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type => "https://in-toto.io/Statement/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The subjects this statement is about (scan + finding artifacts).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<PolicyDecisionSubject> Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The predicate type URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType => "stella.ops/policy-decision@v1";
|
||||
|
||||
/// <summary>
|
||||
/// The policy decision predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required PolicyDecisionPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in a policy decision statement.
|
||||
/// </summary>
|
||||
public sealed record PolicyDecisionSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// The name or identifier of the subject (e.g., scan ID, finding ID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digests of the subject in algorithm:hex format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate payload for policy decision attestations.
|
||||
/// </summary>
|
||||
public sealed record PolicyDecisionPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The finding ID this decision applies to (CVE@PURL format).
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The CVE identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("component_purl")]
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The policy decision result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required PolicyDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The reasoning behind the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoning")]
|
||||
public required PolicyDecisionReasoning Reasoning { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// References to evidence artifacts used in the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_refs")]
|
||||
public required IReadOnlyList<string> EvidenceRefs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was evaluated (UTC ISO 8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluated_at")]
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision expires (UTC ISO 8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the policy used for evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_version")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the policy configuration used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_hash")]
|
||||
public string? PolicyHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy decision type.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum PolicyDecision
|
||||
{
|
||||
/// <summary>Finding is allowed (low risk or mitigated).</summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>Finding requires review.</summary>
|
||||
Review,
|
||||
|
||||
/// <summary>Finding is blocked (high risk).</summary>
|
||||
Block,
|
||||
|
||||
/// <summary>Finding is suppressed by policy.</summary>
|
||||
Suppress,
|
||||
|
||||
/// <summary>Finding is escalated for immediate attention.</summary>
|
||||
Escalate
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reasoning details for a policy decision.
|
||||
/// </summary>
|
||||
public sealed record PolicyDecisionReasoning
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of policy rules evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rules_evaluated")]
|
||||
public required int RulesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Names of policy rules that matched.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rules_matched")]
|
||||
public required IReadOnlyList<string> RulesMatched { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final computed risk score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("final_score")]
|
||||
public required double FinalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk multiplier applied (1.0 = no change, <1 = reduced, >1 = amplified).
|
||||
/// </summary>
|
||||
[JsonPropertyName("risk_multiplier")]
|
||||
public required double RiskMultiplier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state used in decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachability_state")]
|
||||
public string? ReachabilityState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status used in decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_status")]
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of the decision rationale.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RichGraphStatement.cs
|
||||
// Sprint: SPRINT_3801_0001_0002_richgraph_attestation
|
||||
// Description: In-toto statement for RichGraph attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for RichGraph computation attestations.
|
||||
/// Predicate type: stella.ops/richgraph@v1
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This statement attests that a RichGraph was computed from a specific
|
||||
/// SBOM and call graph, producing a content-addressed graph digest.
|
||||
/// </remarks>
|
||||
public sealed record RichGraphStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// The statement type, always "https://in-toto.io/Statement/v1".
|
||||
/// </summary>
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type => "https://in-toto.io/Statement/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The subjects this statement is about (scan + graph artifacts).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<RichGraphSubject> Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The predicate type URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType => "stella.ops/richgraph@v1";
|
||||
|
||||
/// <summary>
|
||||
/// The RichGraph predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required RichGraphPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in a RichGraph statement.
|
||||
/// </summary>
|
||||
public sealed record RichGraphSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// The name or identifier of the subject (e.g., scan ID, graph ID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digests of the subject in algorithm:hex format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate payload for RichGraph attestations.
|
||||
/// </summary>
|
||||
public sealed record RichGraphPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The RichGraph identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graph_id")]
|
||||
public required string GraphId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the RichGraph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graph_digest")]
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of nodes in the graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("node_count")]
|
||||
public required int NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of edges in the graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edge_count")]
|
||||
public required int EdgeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of root nodes (entrypoints) in the graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("root_count")]
|
||||
public required int RootCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about the analyzer that computed the graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzer")]
|
||||
public required RichGraphAnalyzerInfo Analyzer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the graph was computed (UTC ISO 8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the graph attestation expires (UTC ISO 8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the source SBOM (digest).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbom_ref")]
|
||||
public string? SbomRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the source call graph (digest).
|
||||
/// </summary>
|
||||
[JsonPropertyName("callgraph_ref")]
|
||||
public string? CallgraphRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Language of the analyzed code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schema version of the RichGraph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema")]
|
||||
public string Schema { get; init; } = "richgraph-v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the analyzer that computed the RichGraph.
|
||||
/// </summary>
|
||||
public sealed record RichGraphAnalyzerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the analyzer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the analyzer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configuration hash used for the analysis.
|
||||
/// </summary>
|
||||
[JsonPropertyName("config_hash")]
|
||||
public string? ConfigHash { get; init; }
|
||||
}
|
||||
@@ -2,6 +2,11 @@ namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public readonly record struct ScanId(string Value)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new ScanId with a random GUID value.
|
||||
/// </summary>
|
||||
public static ScanId New() => new(Guid.NewGuid().ToString("D"));
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static bool TryParse(string? value, out ScanId scanId)
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ApprovalEndpoints.cs
|
||||
// Sprint: SPRINT_3801_0001_0005_approvals_api
|
||||
// Description: HTTP endpoints for human approval workflow.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for human approval workflow.
|
||||
/// </summary>
|
||||
internal static class ApprovalEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps approval endpoints to the scans route group.
|
||||
/// </summary>
|
||||
public static void MapApprovalEndpoints(this RouteGroupBuilder scansGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scansGroup);
|
||||
|
||||
// POST /scans/{scanId}/approvals
|
||||
scansGroup.MapPost("/{scanId}/approvals", HandleCreateApprovalAsync)
|
||||
.WithName("scanner.scans.approvals.create")
|
||||
.WithTags("Approvals")
|
||||
.WithDescription("Creates a human approval attestation for a finding.")
|
||||
.Produces<ApprovalResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.RequireAuthorization(ScannerPolicies.ScansApprove);
|
||||
|
||||
// GET /scans/{scanId}/approvals
|
||||
scansGroup.MapGet("/{scanId}/approvals", HandleListApprovalsAsync)
|
||||
.WithName("scanner.scans.approvals.list")
|
||||
.WithTags("Approvals")
|
||||
.WithDescription("Lists all active approvals for a scan.")
|
||||
.Produces<ApprovalListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /scans/{scanId}/approvals/{findingId}
|
||||
scansGroup.MapGet("/{scanId}/approvals/{findingId}", HandleGetApprovalAsync)
|
||||
.WithName("scanner.scans.approvals.get")
|
||||
.WithTags("Approvals")
|
||||
.WithDescription("Gets an approval for a specific finding.")
|
||||
.Produces<ApprovalResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// DELETE /scans/{scanId}/approvals/{findingId}
|
||||
scansGroup.MapDelete("/{scanId}/approvals/{findingId}", HandleRevokeApprovalAsync)
|
||||
.WithName("scanner.scans.approvals.revoke")
|
||||
.WithTags("Approvals")
|
||||
.WithDescription("Revokes an existing approval.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansApprove);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateApprovalAsync(
|
||||
string scanId,
|
||||
CreateApprovalRequest request,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
IAttestationChainVerifier chainVerifier,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(approvalService);
|
||||
ArgumentNullException.ThrowIfNull(chainVerifier);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Request body is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.FindingId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"FindingId is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Justification))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Justification is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// Extract approver from claims
|
||||
var approverInfo = ExtractApproverInfo(context.User);
|
||||
if (string.IsNullOrWhiteSpace(approverInfo.UserId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Unable to identify approver",
|
||||
StatusCodes.Status401Unauthorized,
|
||||
detail: "User identity could not be determined from the request.");
|
||||
}
|
||||
|
||||
// Parse the decision
|
||||
if (!Enum.TryParse<ApprovalDecision>(request.Decision, ignoreCase: true, out var decision))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid decision value",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"Decision must be one of: AcceptRisk, Defer, Reject, Suppress, Escalate. Got: {request.Decision}");
|
||||
}
|
||||
|
||||
// Create the approval
|
||||
var input = new HumanApprovalAttestationInput
|
||||
{
|
||||
ScanId = parsed,
|
||||
FindingId = request.FindingId,
|
||||
Decision = decision,
|
||||
ApproverUserId = approverInfo.UserId,
|
||||
ApproverDisplayName = approverInfo.DisplayName,
|
||||
ApproverRole = approverInfo.Role,
|
||||
Justification = request.Justification,
|
||||
PolicyDecisionRef = request.PolicyDecisionRef,
|
||||
Restrictions = request.Restrictions,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
var result = await approvalService.CreateAttestationAsync(input, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Internal,
|
||||
"Failed to create approval",
|
||||
StatusCodes.Status500InternalServerError,
|
||||
detail: result.Error);
|
||||
}
|
||||
|
||||
// Get chain status
|
||||
ChainStatus? chainStatus = null;
|
||||
try
|
||||
{
|
||||
var chainInput = new ChainVerificationInput
|
||||
{
|
||||
ScanId = parsed,
|
||||
FindingId = request.FindingId,
|
||||
RootDigest = result.AttestationId!
|
||||
};
|
||||
var chainResult = await chainVerifier.VerifyChainAsync(chainInput, cancellationToken);
|
||||
chainStatus = chainResult.Chain?.Status;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Chain verification is optional, don't fail the request
|
||||
}
|
||||
|
||||
var response = MapToResponse(result, chainStatus);
|
||||
return Results.Created($"/{scanId}/approvals/{request.FindingId}", response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListApprovalsAsync(
|
||||
string scanId,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(approvalService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var approvals = await approvalService.GetApprovalsByScanAsync(parsed, cancellationToken);
|
||||
|
||||
var response = new ApprovalListResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
Approvals = approvals.Select(a => MapToResponse(a, null)).ToList(),
|
||||
TotalCount = approvals.Count
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetApprovalAsync(
|
||||
string scanId,
|
||||
string findingId,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(approvalService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"FindingId is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var result = await approvalService.GetAttestationAsync(parsed, findingId, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Approval not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: $"No approval found for finding '{findingId}' in scan '{scanId}'.");
|
||||
}
|
||||
|
||||
return Results.Ok(MapToResponse(result, null));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRevokeApprovalAsync(
|
||||
string scanId,
|
||||
string findingId,
|
||||
RevokeApprovalRequest? request,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(approvalService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"FindingId is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var revoker = ExtractApproverInfo(context.User);
|
||||
if (string.IsNullOrWhiteSpace(revoker.UserId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Unable to identify revoker",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var reason = request?.Reason ?? "Revoked via API";
|
||||
|
||||
var revoked = await approvalService.RevokeApprovalAsync(
|
||||
parsed,
|
||||
findingId,
|
||||
revoker.UserId,
|
||||
reason,
|
||||
cancellationToken);
|
||||
|
||||
if (!revoked)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Approval not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: $"No approval found for finding '{findingId}' in scan '{scanId}'.");
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static (string UserId, string? DisplayName, string? Role) ExtractApproverInfo(ClaimsPrincipal? user)
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
return (string.Empty, null, null);
|
||||
}
|
||||
|
||||
// Try various claim types for user ID
|
||||
var userId = user.FindFirstValue(ClaimTypes.Email)
|
||||
?? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user.FindFirstValue("sub")
|
||||
?? user.FindFirstValue("preferred_username")
|
||||
?? string.Empty;
|
||||
|
||||
var displayName = user.FindFirstValue(ClaimTypes.Name)
|
||||
?? user.FindFirstValue("name");
|
||||
|
||||
var role = user.FindFirstValue(ClaimTypes.Role)
|
||||
?? user.FindFirstValue("role");
|
||||
|
||||
return (userId, displayName, role);
|
||||
}
|
||||
|
||||
private static ApprovalResponse MapToResponse(
|
||||
HumanApprovalAttestationResult result,
|
||||
ChainStatus? chainStatus)
|
||||
{
|
||||
var statement = result.Statement!;
|
||||
var predicate = statement.Predicate;
|
||||
|
||||
return new ApprovalResponse
|
||||
{
|
||||
ApprovalId = predicate.ApprovalId,
|
||||
FindingId = predicate.FindingId,
|
||||
Decision = predicate.Decision.ToString(),
|
||||
AttestationId = result.AttestationId!,
|
||||
Approver = predicate.Approver.UserId,
|
||||
ApproverDisplayName = predicate.Approver.DisplayName,
|
||||
ApprovedAt = predicate.ApprovedAt,
|
||||
ExpiresAt = predicate.ExpiresAt ?? predicate.ApprovedAt.AddDays(30),
|
||||
Justification = predicate.Justification,
|
||||
ChainStatus = chainStatus?.ToString(),
|
||||
IsRevoked = result.IsRevoked,
|
||||
PolicyDecisionRef = predicate.PolicyDecisionRef,
|
||||
Restrictions = predicate.Restrictions
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an approval.
|
||||
/// </summary>
|
||||
public sealed record CreateApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The finding ID (e.g., CVE identifier).
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approval decision: AcceptRisk, Defer, Reject, Suppress, Escalate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required string Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy decision attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_decision_ref")]
|
||||
public string? PolicyDecisionRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional restrictions on the approval scope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("restrictions")]
|
||||
public ApprovalRestrictions? Restrictions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an approval.
|
||||
/// </summary>
|
||||
public sealed record RevokeApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for an approval.
|
||||
/// </summary>
|
||||
public sealed record ApprovalResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The approval ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approval_id")]
|
||||
public required string ApprovalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The finding ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approval decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required string Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestation_id")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's user ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approver")]
|
||||
public required string Approver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's display name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approver_display_name")]
|
||||
public string? ApproverDisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the approval was made.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approved_at")]
|
||||
public required DateTimeOffset ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the approval expires.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The justification for the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation chain status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("chain_status")]
|
||||
public string? ChainStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the approval has been revoked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_revoked")]
|
||||
public bool IsRevoked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy decision attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_decision_ref")]
|
||||
public string? PolicyDecisionRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Restrictions on the approval scope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("restrictions")]
|
||||
public ApprovalRestrictions? Restrictions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing approvals.
|
||||
/// </summary>
|
||||
public sealed record ApprovalListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scan_id")]
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The list of approvals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvals")]
|
||||
public required IList<ApprovalResponse> Approvals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count of approvals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("total_count")]
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceEndpoints.cs
|
||||
// Sprint: SPRINT_3800_0003_0001_evidence_api_endpoint
|
||||
// Description: HTTP endpoints for unified finding evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for retrieving unified finding evidence.
|
||||
/// </summary>
|
||||
internal static class EvidenceEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps evidence endpoints to the scans route group.
|
||||
/// </summary>
|
||||
public static void MapEvidenceEndpoints(this RouteGroupBuilder scansGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scansGroup);
|
||||
|
||||
// GET /scans/{scanId}/evidence/{findingId}
|
||||
scansGroup.MapGet("/{scanId}/evidence/{findingId}", HandleGetEvidenceAsync)
|
||||
.WithName("scanner.scans.evidence.get")
|
||||
.WithTags("Evidence")
|
||||
.WithDescription("Retrieves unified evidence for a specific finding within a scan.")
|
||||
.Produces<FindingEvidenceResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /scans/{scanId}/evidence (list all findings with evidence)
|
||||
scansGroup.MapGet("/{scanId}/evidence", HandleListEvidenceAsync)
|
||||
.WithName("scanner.scans.evidence.list")
|
||||
.WithTags("Evidence")
|
||||
.WithDescription("Lists all findings with evidence for a scan.")
|
||||
.Produces<EvidenceListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetEvidenceAsync(
|
||||
string scanId,
|
||||
string findingId,
|
||||
IEvidenceCompositionService evidenceService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidenceService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid finding identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Finding identifier is required.");
|
||||
}
|
||||
|
||||
var evidence = await evidenceService.GetEvidenceAsync(
|
||||
parsed,
|
||||
findingId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (evidence is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Finding not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "The requested finding could not be located in this scan.");
|
||||
}
|
||||
|
||||
// Add warning header if evidence is stale or near expiry
|
||||
if (evidence.IsStale)
|
||||
{
|
||||
context.Response.Headers["X-Evidence-Warning"] = "stale";
|
||||
}
|
||||
else if (evidence.ExpiresAt.HasValue)
|
||||
{
|
||||
var timeUntilExpiry = evidence.ExpiresAt.Value - DateTimeOffset.UtcNow;
|
||||
if (timeUntilExpiry <= TimeSpan.FromDays(1))
|
||||
{
|
||||
context.Response.Headers["X-Evidence-Warning"] = "near-expiry";
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Json(evidence, SerializerOptions, contentType: "application/json", statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListEvidenceAsync(
|
||||
string scanId,
|
||||
IEvidenceCompositionService evidenceService,
|
||||
IReachabilityQueryService reachabilityService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidenceService);
|
||||
ArgumentNullException.ThrowIfNull(reachabilityService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
// Get all findings for the scan
|
||||
var findings = await reachabilityService.GetFindingsAsync(
|
||||
parsed,
|
||||
cveFilter: null,
|
||||
statusFilter: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (findings.Count == 0)
|
||||
{
|
||||
return Results.Json(
|
||||
new EvidenceListResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
TotalCount = 0,
|
||||
Items = Array.Empty<EvidenceSummary>()
|
||||
},
|
||||
SerializerOptions,
|
||||
contentType: "application/json",
|
||||
statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
// Build summary list (without fetching full evidence for performance)
|
||||
var items = findings.Select(f => new EvidenceSummary
|
||||
{
|
||||
FindingId = $"{f.CveId}@{f.Purl}",
|
||||
Cve = f.CveId,
|
||||
Purl = f.Purl,
|
||||
ReachabilityStatus = f.Status,
|
||||
Confidence = f.Confidence,
|
||||
HasPath = f.Status.Equals("reachable", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.Status.Equals("direct", StringComparison.OrdinalIgnoreCase)
|
||||
}).ToList();
|
||||
|
||||
return Results.Json(
|
||||
new EvidenceListResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
TotalCount = items.Count,
|
||||
Items = items
|
||||
},
|
||||
SerializerOptions,
|
||||
contentType: "application/json",
|
||||
statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of evidence summaries.
|
||||
/// </summary>
|
||||
public sealed record EvidenceListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan identifier.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("scan_id")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of findings with evidence.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("total_count")]
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of each finding's evidence.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("items")]
|
||||
public IReadOnlyList<EvidenceSummary> Items { get; init; } = Array.Empty<EvidenceSummary>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a finding's evidence (for list view).
|
||||
/// </summary>
|
||||
public sealed record EvidenceSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier (CVE@PURL format).
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("finding_id")]
|
||||
public string FindingId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("cve")]
|
||||
public string Cve { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("purl")]
|
||||
public string Purl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("reachability_status")]
|
||||
public string ReachabilityStatus { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a reachable path exists.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("has_path")]
|
||||
public bool HasPath { get; init; }
|
||||
}
|
||||
@@ -85,6 +85,8 @@ internal static class ScanEndpoints
|
||||
scans.MapReachabilityEndpoints();
|
||||
scans.MapReachabilityDriftScanEndpoints();
|
||||
scans.MapExportEndpoints();
|
||||
scans.MapEvidenceEndpoints();
|
||||
scans.MapApprovalEndpoints();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSubmitAsync(
|
||||
|
||||
@@ -118,6 +118,11 @@ builder.Services.AddSingleton<IReachabilityExplainService, NullReachabilityExpla
|
||||
builder.Services.AddSingleton<ISarifExportService, NullSarifExportService>();
|
||||
builder.Services.AddSingleton<ICycloneDxExportService, NullCycloneDxExportService>();
|
||||
builder.Services.AddSingleton<IOpenVexExportService, NullOpenVexExportService>();
|
||||
builder.Services.AddSingleton<IEvidenceCompositionService, EvidenceCompositionService>();
|
||||
builder.Services.AddSingleton<IPolicyDecisionAttestationService, PolicyDecisionAttestationService>();
|
||||
builder.Services.AddSingleton<IRichGraphAttestationService, RichGraphAttestationService>();
|
||||
builder.Services.AddSingleton<IAttestationChainVerifier, AttestationChainVerifier>();
|
||||
builder.Services.AddSingleton<IHumanApprovalAttestationService, HumanApprovalAttestationService>();
|
||||
builder.Services.AddScoped<ICallGraphIngestionService, CallGraphIngestionService>();
|
||||
builder.Services.AddScoped<ISbomIngestionService, SbomIngestionService>();
|
||||
builder.Services.AddSingleton<IPolicySnapshotRepository, InMemoryPolicySnapshotRepository>();
|
||||
@@ -340,6 +345,7 @@ if (bootstrapOptions.Authority.Enabled)
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansEnqueue, bootstrapOptions.Authority.RequiredScopes.ToArray());
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansRead, ScannerAuthorityScopes.ScansRead);
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansWrite, ScannerAuthorityScopes.ScansWrite);
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansApprove, ScannerAuthorityScopes.ScansWrite);
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.Reports, ScannerAuthorityScopes.ReportsRead);
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.RuntimeIngest, ScannerAuthorityScopes.RuntimeIngest);
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.CallGraphIngest, ScannerAuthorityScopes.CallGraphIngest);
|
||||
@@ -361,6 +367,7 @@ else
|
||||
options.AddPolicy(ScannerPolicies.ScansEnqueue, policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy(ScannerPolicies.ScansRead, policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy(ScannerPolicies.ScansWrite, policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy(ScannerPolicies.ScansApprove, policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy(ScannerPolicies.Reports, policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy(ScannerPolicies.RuntimeIngest, policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy(ScannerPolicies.CallGraphIngest, policy => policy.RequireAssertion(_ => true));
|
||||
@@ -369,6 +376,9 @@ else
|
||||
});
|
||||
}
|
||||
|
||||
// Evidence composition configuration
|
||||
builder.Services.Configure<EvidenceCompositionOptions>(builder.Configuration.GetSection("EvidenceComposition"));
|
||||
|
||||
// Concelier Linkset integration for advisory enrichment
|
||||
builder.Services.Configure<ConcelierLinksetOptions>(builder.Configuration.GetSection(ConcelierLinksetOptions.SectionName));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ internal static class ScannerPolicies
|
||||
public const string ScansEnqueue = "scanner.api";
|
||||
public const string ScansRead = "scanner.scans.read";
|
||||
public const string ScansWrite = "scanner.scans.write";
|
||||
public const string ScansApprove = "scanner.scans.approve";
|
||||
public const string Reports = "scanner.reports";
|
||||
public const string RuntimeIngest = "scanner.runtime.ingest";
|
||||
public const string CallGraphIngest = "scanner.callgraph.ingest";
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationChainVerifier.cs
|
||||
// Sprint: SPRINT_3801_0001_0003_chain_verification (CHAIN-003)
|
||||
// Description: Verifies attestation chain integrity.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
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>
|
||||
/// Verifies attestation chain integrity.
|
||||
/// </summary>
|
||||
public sealed class AttestationChainVerifier : IAttestationChainVerifier
|
||||
{
|
||||
private readonly ILogger<AttestationChainVerifier> _logger;
|
||||
private readonly AttestationChainVerifierOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IPolicyDecisionAttestationService _policyAttestationService;
|
||||
private readonly IRichGraphAttestationService _richGraphAttestationService;
|
||||
private readonly IHumanApprovalAttestationService _humanApprovalAttestationService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="AttestationChainVerifier"/>.
|
||||
/// </summary>
|
||||
public AttestationChainVerifier(
|
||||
ILogger<AttestationChainVerifier> logger,
|
||||
IOptions<AttestationChainVerifierOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
IPolicyDecisionAttestationService policyAttestationService,
|
||||
IRichGraphAttestationService richGraphAttestationService,
|
||||
IHumanApprovalAttestationService humanApprovalAttestationService)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_policyAttestationService = policyAttestationService ?? throw new ArgumentNullException(nameof(policyAttestationService));
|
||||
_richGraphAttestationService = richGraphAttestationService ?? throw new ArgumentNullException(nameof(richGraphAttestationService));
|
||||
_humanApprovalAttestationService = humanApprovalAttestationService ?? throw new ArgumentNullException(nameof(humanApprovalAttestationService));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChainVerificationResult> VerifyChainAsync(
|
||||
ChainVerificationInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.FindingId))
|
||||
{
|
||||
throw new ArgumentException("FindingId is required", nameof(input));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.RootDigest))
|
||||
{
|
||||
throw new ArgumentException("RootDigest is required", nameof(input));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verifying attestation chain for scan {ScanId}, finding {FindingId}",
|
||||
input.ScanId,
|
||||
input.FindingId);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var details = new List<AttestationVerificationDetail>();
|
||||
var attestations = new List<ChainAttestation>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var hasFailures = false;
|
||||
var hasExpired = false;
|
||||
|
||||
// Collect attestations in chain order
|
||||
// 1. RichGraph attestation (reachability analysis)
|
||||
var richGraphResult = await VerifyRichGraphAttestationAsync(
|
||||
input.ScanId,
|
||||
input.FindingId,
|
||||
now,
|
||||
input.ExpirationGracePeriod,
|
||||
cancellationToken);
|
||||
|
||||
if (richGraphResult.Detail != null)
|
||||
{
|
||||
details.Add(richGraphResult.Detail);
|
||||
}
|
||||
|
||||
if (richGraphResult.Attestation != null)
|
||||
{
|
||||
attestations.Add(richGraphResult.Attestation);
|
||||
}
|
||||
|
||||
hasFailures |= richGraphResult.IsFailed;
|
||||
hasExpired |= richGraphResult.IsExpired;
|
||||
|
||||
// 2. Policy decision attestation
|
||||
var policyResult = await VerifyPolicyAttestationAsync(
|
||||
input.ScanId,
|
||||
input.FindingId,
|
||||
now,
|
||||
input.ExpirationGracePeriod,
|
||||
cancellationToken);
|
||||
|
||||
if (policyResult.Detail != null)
|
||||
{
|
||||
details.Add(policyResult.Detail);
|
||||
}
|
||||
|
||||
if (policyResult.Attestation != null)
|
||||
{
|
||||
attestations.Add(policyResult.Attestation);
|
||||
}
|
||||
|
||||
hasFailures |= policyResult.IsFailed;
|
||||
hasExpired |= policyResult.IsExpired;
|
||||
|
||||
// 3. Human approval attestation
|
||||
var humanApprovalResult = await VerifyHumanApprovalAttestationAsync(
|
||||
input.ScanId,
|
||||
input.FindingId,
|
||||
now,
|
||||
input.ExpirationGracePeriod,
|
||||
cancellationToken);
|
||||
|
||||
if (humanApprovalResult.Detail != null)
|
||||
{
|
||||
details.Add(humanApprovalResult.Detail);
|
||||
}
|
||||
|
||||
if (humanApprovalResult.Attestation != null)
|
||||
{
|
||||
attestations.Add(humanApprovalResult.Attestation);
|
||||
}
|
||||
|
||||
hasFailures |= humanApprovalResult.IsFailed;
|
||||
hasExpired |= humanApprovalResult.IsExpired;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Determine chain status
|
||||
var chainStatus = DetermineChainStatus(
|
||||
attestations,
|
||||
hasFailures,
|
||||
hasExpired,
|
||||
input.RequiredTypes,
|
||||
input.RequireHumanApproval);
|
||||
|
||||
// Build the chain
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
ChainId = ComputeChainId(input.ScanId, input.FindingId, input.RootDigest),
|
||||
ScanId = input.ScanId.ToString(),
|
||||
FindingId = input.FindingId,
|
||||
RootDigest = input.RootDigest,
|
||||
Attestations = attestations.ToImmutableList(),
|
||||
Verified = chainStatus == ChainStatus.Complete,
|
||||
VerifiedAt = now,
|
||||
Status = chainStatus,
|
||||
ExpiresAt = GetEarliestExpiration(attestations)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chain verification completed in {ElapsedMs}ms: {Status} with {Count} attestations",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
chainStatus,
|
||||
attestations.Count);
|
||||
|
||||
if (chainStatus == ChainStatus.Complete)
|
||||
{
|
||||
return ChainVerificationResult.Succeeded(chain, details);
|
||||
}
|
||||
|
||||
var errorMessage = chainStatus switch
|
||||
{
|
||||
ChainStatus.Expired => "One or more attestations have expired",
|
||||
ChainStatus.Invalid => "Signature verification failed or attestation revoked",
|
||||
ChainStatus.Broken => "Chain link broken or digest mismatch",
|
||||
ChainStatus.Partial => "Required attestations are missing",
|
||||
ChainStatus.Empty => "No attestations found in chain",
|
||||
_ => "Chain verification failed"
|
||||
};
|
||||
|
||||
// Include details in failure result so callers can inspect why it failed
|
||||
return new ChainVerificationResult
|
||||
{
|
||||
Success = false,
|
||||
Chain = chain,
|
||||
Error = errorMessage,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChain?> GetChainAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scanId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var attestations = new List<ChainAttestation>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Collect attestations (without full verification)
|
||||
// Note: This is a simplified implementation; in production we'd have a more
|
||||
// efficient way to query attestations by finding ID
|
||||
|
||||
// For now, we return null since we don't have a lookup by finding ID
|
||||
// The full implementation would query attestation stores
|
||||
_logger.LogDebug(
|
||||
"GetChainAsync called for scan {ScanId}, finding {FindingId}",
|
||||
scanId,
|
||||
findingId);
|
||||
|
||||
// Placeholder: return null until we have proper attestation indexing
|
||||
await Task.CompletedTask;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsChainComplete(AttestationChain chain, params AttestationType[] requiredTypes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chain);
|
||||
|
||||
if (requiredTypes.Length == 0)
|
||||
{
|
||||
return chain.Attestations.Count > 0;
|
||||
}
|
||||
|
||||
var presentTypes = chain.Attestations
|
||||
.Where(a => a.Verified)
|
||||
.Select(a => a.Type)
|
||||
.ToHashSet();
|
||||
|
||||
return requiredTypes.All(t => presentTypes.Contains(t));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset? GetEarliestExpiration(AttestationChain chain)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chain);
|
||||
return GetEarliestExpiration(chain.Attestations);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetEarliestExpiration(IEnumerable<ChainAttestation> attestations)
|
||||
{
|
||||
var expirations = attestations
|
||||
.Where(a => a.Verified)
|
||||
.Select(a => a.ExpiresAt)
|
||||
.ToList();
|
||||
|
||||
return expirations.Count > 0 ? expirations.Min() : null;
|
||||
}
|
||||
|
||||
private async Task<AttestationVerificationResult> VerifyRichGraphAttestationAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
DateTimeOffset now,
|
||||
TimeSpan gracePeriod,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Try to get the RichGraph attestation
|
||||
// Note: We use the finding ID as the graph ID for lookup
|
||||
// In practice, we'd have a mapping from finding to graph
|
||||
var attestation = await _richGraphAttestationService.GetAttestationAsync(
|
||||
scanId,
|
||||
findingId,
|
||||
cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (attestation == null)
|
||||
{
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.RichGraph,
|
||||
AttestationId = "not-found",
|
||||
Status = AttestationVerificationStatus.NotFound,
|
||||
Verified = false,
|
||||
VerificationTime = stopwatch.Elapsed,
|
||||
Error = "RichGraph attestation not found"
|
||||
},
|
||||
IsFailed = false, // Not found is partial, not failed
|
||||
IsExpired = false
|
||||
};
|
||||
}
|
||||
|
||||
var statement = attestation.Statement!;
|
||||
var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(7);
|
||||
var isExpired = now > expiresAt.Add(gracePeriod);
|
||||
|
||||
var chainAttestation = new ChainAttestation
|
||||
{
|
||||
Type = AttestationType.RichGraph,
|
||||
AttestationId = attestation.AttestationId!,
|
||||
CreatedAt = statement.Predicate.ComputedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
Verified = !isExpired,
|
||||
VerificationStatus = isExpired
|
||||
? AttestationVerificationStatus.Expired
|
||||
: AttestationVerificationStatus.Valid,
|
||||
SubjectDigest = statement.Predicate.GraphDigest,
|
||||
PredicateType = statement.PredicateType
|
||||
};
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Attestation = chainAttestation,
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.RichGraph,
|
||||
AttestationId = attestation.AttestationId!,
|
||||
Status = chainAttestation.VerificationStatus,
|
||||
Verified = chainAttestation.Verified,
|
||||
VerificationTime = stopwatch.Elapsed
|
||||
},
|
||||
IsFailed = false,
|
||||
IsExpired = isExpired
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogWarning(ex, "Failed to verify RichGraph attestation for scan {ScanId}", scanId);
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.RichGraph,
|
||||
AttestationId = "error",
|
||||
Status = AttestationVerificationStatus.ChainBroken,
|
||||
Verified = false,
|
||||
VerificationTime = stopwatch.Elapsed,
|
||||
Error = ex.Message
|
||||
},
|
||||
IsFailed = true,
|
||||
IsExpired = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AttestationVerificationResult> VerifyPolicyAttestationAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
DateTimeOffset now,
|
||||
TimeSpan gracePeriod,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Try to get the policy attestation
|
||||
var attestation = await _policyAttestationService.GetAttestationAsync(
|
||||
scanId,
|
||||
findingId,
|
||||
cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (attestation == null)
|
||||
{
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.PolicyDecision,
|
||||
AttestationId = "not-found",
|
||||
Status = AttestationVerificationStatus.NotFound,
|
||||
Verified = false,
|
||||
VerificationTime = stopwatch.Elapsed,
|
||||
Error = "Policy decision attestation not found"
|
||||
},
|
||||
IsFailed = false, // Not found is partial, not failed
|
||||
IsExpired = false
|
||||
};
|
||||
}
|
||||
|
||||
var statement = attestation.Statement!;
|
||||
var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(7);
|
||||
var isExpired = now > expiresAt.Add(gracePeriod);
|
||||
|
||||
var chainAttestation = new ChainAttestation
|
||||
{
|
||||
Type = AttestationType.PolicyDecision,
|
||||
AttestationId = attestation.AttestationId!,
|
||||
CreatedAt = statement.Predicate.EvaluatedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
Verified = !isExpired,
|
||||
VerificationStatus = isExpired
|
||||
? AttestationVerificationStatus.Expired
|
||||
: AttestationVerificationStatus.Valid,
|
||||
SubjectDigest = statement.Subject[0].Digest["sha256"],
|
||||
PredicateType = statement.PredicateType
|
||||
};
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Attestation = chainAttestation,
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.PolicyDecision,
|
||||
AttestationId = attestation.AttestationId!,
|
||||
Status = chainAttestation.VerificationStatus,
|
||||
Verified = chainAttestation.Verified,
|
||||
VerificationTime = stopwatch.Elapsed
|
||||
},
|
||||
IsFailed = false,
|
||||
IsExpired = isExpired
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogWarning(ex, "Failed to verify policy attestation for scan {ScanId}", scanId);
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.PolicyDecision,
|
||||
AttestationId = "error",
|
||||
Status = AttestationVerificationStatus.ChainBroken,
|
||||
Verified = false,
|
||||
VerificationTime = stopwatch.Elapsed,
|
||||
Error = ex.Message
|
||||
},
|
||||
IsFailed = true,
|
||||
IsExpired = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AttestationVerificationResult> VerifyHumanApprovalAttestationAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
DateTimeOffset now,
|
||||
TimeSpan gracePeriod,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Try to get the human approval attestation
|
||||
var attestation = await _humanApprovalAttestationService.GetAttestationAsync(
|
||||
scanId,
|
||||
findingId,
|
||||
cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (attestation == null)
|
||||
{
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.HumanApproval,
|
||||
AttestationId = "not-found",
|
||||
Status = AttestationVerificationStatus.NotFound,
|
||||
Verified = false,
|
||||
VerificationTime = stopwatch.Elapsed,
|
||||
Error = "Human approval attestation not found"
|
||||
},
|
||||
IsFailed = false, // Not found is partial, not failed
|
||||
IsExpired = false
|
||||
};
|
||||
}
|
||||
|
||||
// Check if attestation was revoked
|
||||
if (attestation.IsRevoked)
|
||||
{
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.HumanApproval,
|
||||
AttestationId = attestation.AttestationId!,
|
||||
Status = AttestationVerificationStatus.Revoked,
|
||||
Verified = false,
|
||||
VerificationTime = stopwatch.Elapsed,
|
||||
Error = "Human approval attestation has been revoked"
|
||||
},
|
||||
IsFailed = true,
|
||||
IsExpired = false
|
||||
};
|
||||
}
|
||||
|
||||
var statement = attestation.Statement!;
|
||||
|
||||
// Default to 30 days (human approval default TTL) if not specified
|
||||
var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(30);
|
||||
var isExpired = now > expiresAt.Add(gracePeriod);
|
||||
|
||||
// Get subject digest if available
|
||||
var subjectDigest = statement.Subject.Count > 0
|
||||
&& statement.Subject[0].Digest.TryGetValue("sha256", out var digest)
|
||||
? digest
|
||||
: string.Empty;
|
||||
|
||||
var chainAttestation = new ChainAttestation
|
||||
{
|
||||
Type = AttestationType.HumanApproval,
|
||||
AttestationId = attestation.AttestationId!,
|
||||
CreatedAt = statement.Predicate.ApprovedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
Verified = !isExpired,
|
||||
VerificationStatus = isExpired
|
||||
? AttestationVerificationStatus.Expired
|
||||
: AttestationVerificationStatus.Valid,
|
||||
SubjectDigest = subjectDigest,
|
||||
PredicateType = statement.PredicateType
|
||||
};
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Attestation = chainAttestation,
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.HumanApproval,
|
||||
AttestationId = attestation.AttestationId!,
|
||||
Status = chainAttestation.VerificationStatus,
|
||||
Verified = chainAttestation.Verified,
|
||||
VerificationTime = stopwatch.Elapsed
|
||||
},
|
||||
IsFailed = false,
|
||||
IsExpired = isExpired
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogWarning(ex, "Failed to verify human approval attestation for scan {ScanId}", scanId);
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Detail = new AttestationVerificationDetail
|
||||
{
|
||||
Type = AttestationType.HumanApproval,
|
||||
AttestationId = "error",
|
||||
Status = AttestationVerificationStatus.ChainBroken,
|
||||
Verified = false,
|
||||
VerificationTime = stopwatch.Elapsed,
|
||||
Error = ex.Message
|
||||
},
|
||||
IsFailed = true,
|
||||
IsExpired = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static ChainStatus DetermineChainStatus(
|
||||
List<ChainAttestation> attestations,
|
||||
bool hasFailures,
|
||||
bool hasExpired,
|
||||
IReadOnlyList<AttestationType>? requiredTypes,
|
||||
bool requireHumanApproval)
|
||||
{
|
||||
if (hasFailures)
|
||||
{
|
||||
return ChainStatus.Invalid;
|
||||
}
|
||||
|
||||
if (attestations.Count == 0)
|
||||
{
|
||||
return ChainStatus.Empty;
|
||||
}
|
||||
|
||||
if (hasExpired)
|
||||
{
|
||||
return ChainStatus.Expired;
|
||||
}
|
||||
|
||||
// Check for broken chain (digest mismatches would be detected during verification)
|
||||
if (attestations.Any(a => a.VerificationStatus == AttestationVerificationStatus.ChainBroken))
|
||||
{
|
||||
return ChainStatus.Broken;
|
||||
}
|
||||
|
||||
// Check for required types
|
||||
var presentTypes = attestations
|
||||
.Where(a => a.Verified)
|
||||
.Select(a => a.Type)
|
||||
.ToHashSet();
|
||||
|
||||
if (requiredTypes != null && requiredTypes.Count > 0)
|
||||
{
|
||||
if (!requiredTypes.All(t => presentTypes.Contains(t)))
|
||||
{
|
||||
return ChainStatus.Partial;
|
||||
}
|
||||
}
|
||||
|
||||
if (requireHumanApproval && !presentTypes.Contains(AttestationType.HumanApproval))
|
||||
{
|
||||
return ChainStatus.Partial;
|
||||
}
|
||||
|
||||
// All verified attestations present
|
||||
return ChainStatus.Complete;
|
||||
}
|
||||
|
||||
private static string ComputeChainId(ScanId scanId, string findingId, string rootDigest)
|
||||
{
|
||||
var input = $"{scanId}|{findingId}|{rootDigest}";
|
||||
return ComputeSha256(input);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private sealed record AttestationVerificationResult
|
||||
{
|
||||
public ChainAttestation? Attestation { get; init; }
|
||||
public AttestationVerificationDetail? Detail { get; init; }
|
||||
public bool IsFailed { get; init; }
|
||||
public bool IsExpired { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation chain verification.
|
||||
/// </summary>
|
||||
public sealed class AttestationChainVerifierOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default grace period for expired attestations in minutes.
|
||||
/// </summary>
|
||||
public int DefaultGracePeriodMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require human approval for high-severity findings.
|
||||
/// </summary>
|
||||
public bool RequireHumanApprovalForHighSeverity { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum chain depth to verify.
|
||||
/// </summary>
|
||||
public int MaxChainDepth { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail on missing attestations vs. reporting partial status.
|
||||
/// </summary>
|
||||
public bool FailOnMissingAttestations { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceCompositionService.cs
|
||||
// Sprint: SPRINT_3800_0003_0001_evidence_api_endpoint
|
||||
// Description: Composes unified evidence responses from multiple sources.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
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>
|
||||
/// Composes unified evidence responses for findings by aggregating data from
|
||||
/// reachability, boundary, VEX, and scoring services.
|
||||
/// </summary>
|
||||
public sealed class EvidenceCompositionService : IEvidenceCompositionService
|
||||
{
|
||||
private readonly IScanCoordinator _scanCoordinator;
|
||||
private readonly IReachabilityQueryService _reachabilityQueryService;
|
||||
private readonly IReachabilityExplainService _reachabilityExplainService;
|
||||
private readonly ILogger<EvidenceCompositionService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly EvidenceCompositionOptions _options;
|
||||
|
||||
public EvidenceCompositionService(
|
||||
IScanCoordinator scanCoordinator,
|
||||
IReachabilityQueryService reachabilityQueryService,
|
||||
IReachabilityExplainService reachabilityExplainService,
|
||||
IOptions<EvidenceCompositionOptions> options,
|
||||
ILogger<EvidenceCompositionService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator));
|
||||
_reachabilityQueryService = reachabilityQueryService ?? throw new ArgumentNullException(nameof(reachabilityQueryService));
|
||||
_reachabilityExplainService = reachabilityExplainService ?? throw new ArgumentNullException(nameof(reachabilityExplainService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new EvidenceCompositionOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FindingEvidenceResponse?> GetEvidenceAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
// Parse finding ID: "CVE-XXXX-XXXXX@pkg:ecosystem/name@version"
|
||||
var (cveId, purl) = ParseFindingId(findingId);
|
||||
if (string.IsNullOrEmpty(cveId) || string.IsNullOrEmpty(purl))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding ID format: {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify scan exists
|
||||
var scan = await _scanCoordinator.GetAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
if (scan is null)
|
||||
{
|
||||
_logger.LogDebug("Scan not found: {ScanId}", scanId.Value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get reachability finding to verify it exists
|
||||
var findings = await _reachabilityQueryService.GetFindingsAsync(
|
||||
scanId,
|
||||
cveFilter: cveId,
|
||||
statusFilter: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var finding = findings.FirstOrDefault(f =>
|
||||
f.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase) &&
|
||||
f.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (finding is null)
|
||||
{
|
||||
_logger.LogDebug("Finding not found: {FindingId} in scan {ScanId}", findingId, scanId.Value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get detailed reachability explanation
|
||||
var explanation = await _reachabilityExplainService.ExplainAsync(
|
||||
scanId,
|
||||
cveId,
|
||||
purl,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Build score explanation (simplified local computation)
|
||||
var scoreExplanation = BuildScoreExplanation(finding, explanation);
|
||||
|
||||
// Compose the response
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Calculate expiry based on evidence sources
|
||||
var (expiresAt, isStale) = CalculateTtlAndStaleness(now, explanation);
|
||||
|
||||
return new FindingEvidenceResponse
|
||||
{
|
||||
FindingId = findingId,
|
||||
Cve = cveId,
|
||||
Component = BuildComponentRef(purl),
|
||||
ReachablePath = explanation?.PathWitness,
|
||||
Entrypoint = BuildEntrypointProof(explanation),
|
||||
Boundary = null, // Boundary extraction requires RichGraph, deferred to SPRINT_3800_0003_0002
|
||||
Vex = null, // VEX requires Excititor query, deferred to SPRINT_3800_0003_0002
|
||||
ScoreExplain = scoreExplanation,
|
||||
LastSeen = now,
|
||||
ExpiresAt = expiresAt,
|
||||
IsStale = isStale,
|
||||
AttestationRefs = BuildAttestationRefs(scan, explanation)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the evidence expiry time and staleness based on evidence sources.
|
||||
/// Uses the minimum expiry time from all evidence sources.
|
||||
/// </summary>
|
||||
private (DateTimeOffset expiresAt, bool isStale) CalculateTtlAndStaleness(
|
||||
DateTimeOffset now,
|
||||
ReachabilityExplanation? explanation)
|
||||
{
|
||||
var defaultTtl = TimeSpan.FromDays(_options.DefaultEvidenceTtlDays);
|
||||
var warningThreshold = TimeSpan.FromDays(_options.StaleWarningThresholdDays);
|
||||
|
||||
// Default: evidence expires from when it was computed (now)
|
||||
var reachabilityExpiry = now.Add(defaultTtl);
|
||||
|
||||
// If we have evidence chain with timestamps, use those instead
|
||||
// For now, we use now as the base timestamp since ReachabilityExplanation
|
||||
// doesn't expose a resolved timestamp. Future enhancement: add timestamp to explanation.
|
||||
|
||||
// VEX expiry would be calculated from VEX timestamp + VexTtl
|
||||
// For now, since VEX is not yet integrated, we skip this
|
||||
// TODO: When VEX is integrated, add: vexExpiry = vexTimestamp.Add(vexTtl);
|
||||
|
||||
// Use the minimum expiry time (evidence chain is as fresh as the oldest source)
|
||||
var expiresAt = reachabilityExpiry;
|
||||
|
||||
// Evidence is stale if it has expired
|
||||
var isStale = expiresAt <= now;
|
||||
|
||||
// Also consider "near-stale" (within warning threshold) for logging
|
||||
if (!isStale && (expiresAt - now) <= warningThreshold)
|
||||
{
|
||||
_logger.LogDebug("Evidence nearing expiry: expires in {TimeRemaining}", expiresAt - now);
|
||||
}
|
||||
|
||||
return (expiresAt, isStale);
|
||||
}
|
||||
|
||||
private static (string? cveId, string? purl) ParseFindingId(string findingId)
|
||||
{
|
||||
// Format: "CVE-XXXX-XXXXX@pkg:ecosystem/name@version"
|
||||
var atIndex = findingId.IndexOf('@');
|
||||
if (atIndex <= 0 || atIndex >= findingId.Length - 1)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var cveId = findingId[..atIndex];
|
||||
var purl = findingId[(atIndex + 1)..];
|
||||
|
||||
// Validate CVE format (basic check)
|
||||
if (!cveId.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// Validate PURL format (basic check)
|
||||
if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
return (cveId, purl);
|
||||
}
|
||||
|
||||
private static ComponentRef BuildComponentRef(string purl)
|
||||
{
|
||||
// Parse PURL: "pkg:ecosystem/name@version"
|
||||
var parts = purl.Replace("pkg:", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Split('/', '@');
|
||||
|
||||
var ecosystem = parts.Length > 0 ? parts[0] : "unknown";
|
||||
var name = parts.Length > 1 ? parts[1] : "unknown";
|
||||
var version = parts.Length > 2 ? parts[2] : "unknown";
|
||||
|
||||
return new ComponentRef
|
||||
{
|
||||
Purl = purl,
|
||||
Name = name,
|
||||
Version = version,
|
||||
Type = ecosystem
|
||||
};
|
||||
}
|
||||
|
||||
private static EntrypointProof? BuildEntrypointProof(ReachabilityExplanation? explanation)
|
||||
{
|
||||
if (explanation?.PathWitness is null || explanation.PathWitness.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstHop = explanation.PathWitness[0];
|
||||
var entrypointType = InferEntrypointType(firstHop);
|
||||
|
||||
return new EntrypointProof
|
||||
{
|
||||
Type = entrypointType,
|
||||
Fqn = firstHop,
|
||||
Phase = "runtime"
|
||||
};
|
||||
}
|
||||
|
||||
private static string InferEntrypointType(string fqn)
|
||||
{
|
||||
var lower = fqn.ToLowerInvariant();
|
||||
if (lower.Contains("controller") || lower.Contains("handler") || lower.Contains("http"))
|
||||
{
|
||||
return "http_handler";
|
||||
}
|
||||
if (lower.Contains("grpc") || lower.Contains("rpc"))
|
||||
{
|
||||
return "grpc_method";
|
||||
}
|
||||
if (lower.Contains("main") || lower.Contains("program"))
|
||||
{
|
||||
return "cli_command";
|
||||
}
|
||||
return "internal";
|
||||
}
|
||||
|
||||
private ScoreExplanationDto BuildScoreExplanation(
|
||||
ReachabilityFinding finding,
|
||||
ReachabilityExplanation? explanation)
|
||||
{
|
||||
// Simplified score computation based on reachability status
|
||||
var contributions = new List<ScoreContributionDto>();
|
||||
double riskScore = 0.0;
|
||||
|
||||
// Reachability contribution (0-25 points)
|
||||
var (reachabilityContribution, reachabilityExplanation) = finding.Status.ToLowerInvariant() switch
|
||||
{
|
||||
"reachable" => (25.0, "Code path leads directly to vulnerable function"),
|
||||
"direct" => (20.0, "Direct dependency call to vulnerable package"),
|
||||
"runtime" => (22.0, "Runtime evidence shows execution path"),
|
||||
"unreachable" => (0.0, "No execution path to vulnerable code"),
|
||||
_ => (12.0, "Reachability unknown, conservative estimate")
|
||||
};
|
||||
|
||||
if (reachabilityContribution > 0)
|
||||
{
|
||||
contributions.Add(new ScoreContributionDto
|
||||
{
|
||||
Factor = "reachability",
|
||||
Weight = 1.0,
|
||||
RawValue = reachabilityContribution,
|
||||
Contribution = reachabilityContribution,
|
||||
Explanation = reachabilityExplanation
|
||||
});
|
||||
riskScore += reachabilityContribution;
|
||||
}
|
||||
|
||||
// Confidence contribution (0-10 points)
|
||||
var confidenceContribution = finding.Confidence * 10.0;
|
||||
contributions.Add(new ScoreContributionDto
|
||||
{
|
||||
Factor = "confidence",
|
||||
Weight = 1.0,
|
||||
RawValue = finding.Confidence,
|
||||
Contribution = confidenceContribution,
|
||||
Explanation = $"Analysis confidence: {finding.Confidence:P0}"
|
||||
});
|
||||
riskScore += confidenceContribution;
|
||||
|
||||
// Gate discount (-10 to 0 points)
|
||||
if (explanation?.Why is not null)
|
||||
{
|
||||
var gateCount = explanation.Why.Count(w =>
|
||||
w.Code.StartsWith("gate_", StringComparison.OrdinalIgnoreCase));
|
||||
if (gateCount > 0)
|
||||
{
|
||||
var gateDiscount = Math.Min(gateCount * -3.0, -10.0);
|
||||
contributions.Add(new ScoreContributionDto
|
||||
{
|
||||
Factor = "gate_protection",
|
||||
Weight = 1.0,
|
||||
RawValue = gateCount,
|
||||
Contribution = gateDiscount,
|
||||
Explanation = $"{gateCount} protective gate(s) detected"
|
||||
});
|
||||
riskScore += gateDiscount;
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp to 0-100
|
||||
riskScore = Math.Clamp(riskScore, 0.0, 100.0);
|
||||
|
||||
return new ScoreExplanationDto
|
||||
{
|
||||
Kind = "stellaops_evidence_v1",
|
||||
RiskScore = riskScore,
|
||||
Contributions = contributions,
|
||||
LastSeen = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? BuildAttestationRefs(
|
||||
ScanSnapshot scan,
|
||||
ReachabilityExplanation? explanation)
|
||||
{
|
||||
var refs = new List<string>();
|
||||
|
||||
// Add scan manifest hash as attestation reference
|
||||
if (scan.Replay?.ManifestHash is not null)
|
||||
{
|
||||
refs.Add(scan.Replay.ManifestHash);
|
||||
}
|
||||
|
||||
// Add spine ID if available
|
||||
if (explanation?.SpineId is not null)
|
||||
{
|
||||
refs.Add(explanation.SpineId);
|
||||
}
|
||||
|
||||
// Add callgraph digest if available
|
||||
if (explanation?.Evidence?.StaticAnalysis?.CallgraphDigest is not null)
|
||||
{
|
||||
refs.Add(explanation.Evidence.StaticAnalysis.CallgraphDigest);
|
||||
}
|
||||
|
||||
return refs.Count > 0 ? refs : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for evidence composition.
|
||||
/// </summary>
|
||||
public sealed class EvidenceCompositionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default TTL for reachability/scan evidence in days.
|
||||
/// </summary>
|
||||
public int DefaultEvidenceTtlDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// TTL for VEX evidence in days (typically longer than scan data).
|
||||
/// </summary>
|
||||
public int VexEvidenceTtlDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Warning threshold before expiry in days. Evidence within this window
|
||||
/// is considered "near-stale" and triggers warnings.
|
||||
/// </summary>
|
||||
public int StaleWarningThresholdDays { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include VEX evidence when available.
|
||||
/// </summary>
|
||||
public bool IncludeVexEvidence { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include boundary proof when available.
|
||||
/// </summary>
|
||||
public bool IncludeBoundaryProof { get; set; } = true;
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IAttestationChainVerifier.cs
|
||||
// Sprint: SPRINT_3801_0001_0003_chain_verification (CHAIN-001)
|
||||
// Description: Interface for verifying attestation chains.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the integrity of attestation chains.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The attestation chain links together multiple attestations to form a
|
||||
/// complete proof of provenance for a finding's triage decision:
|
||||
/// <list type="bullet">
|
||||
/// <item>RichGraph attestation: proves the reachability analysis</item>
|
||||
/// <item>PolicyDecision attestation: proves the policy evaluation</item>
|
||||
/// <item>HumanApproval attestation: proves human review (when required)</item>
|
||||
/// </list>
|
||||
/// Each attestation in the chain references the digest of the previous,
|
||||
/// creating a verifiable chain back to the original scan.
|
||||
/// </remarks>
|
||||
public interface IAttestationChainVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies an attestation chain for a given finding.
|
||||
/// </summary>
|
||||
/// <param name="input">The verification input parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="ChainVerificationResult"/> indicating whether the chain
|
||||
/// is valid and providing detailed verification status for each attestation.
|
||||
/// </returns>
|
||||
Task<ChainVerificationResult> VerifyChainAsync(
|
||||
ChainVerificationInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chain of attestations for a finding without verifying signatures.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan ID.</param>
|
||||
/// <param name="findingId">The finding ID (e.g., CVE identifier).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The attestation chain if found, or null if no attestations exist.
|
||||
/// </returns>
|
||||
Task<AttestationChain?> GetChainAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a chain is complete (has all required attestation types).
|
||||
/// </summary>
|
||||
/// <param name="chain">The attestation chain.</param>
|
||||
/// <param name="requiredTypes">Required attestation types.</param>
|
||||
/// <returns>True if the chain contains all required types.</returns>
|
||||
bool IsChainComplete(
|
||||
AttestationChain chain,
|
||||
params AttestationType[] requiredTypes);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest expiration time in the chain.
|
||||
/// </summary>
|
||||
/// <param name="chain">The attestation chain.</param>
|
||||
/// <returns>The earliest expiration time, or null if the chain is empty.</returns>
|
||||
DateTimeOffset? GetEarliestExpiration(AttestationChain chain);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IEvidenceCompositionService.cs
|
||||
// Sprint: SPRINT_3800_0003_0001_evidence_api_endpoint
|
||||
// Description: Interface for composing unified evidence responses.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for composing unified evidence responses for findings.
|
||||
/// Aggregates evidence from reachability, boundary, VEX, and scoring services.
|
||||
/// </summary>
|
||||
public interface IEvidenceCompositionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets composed evidence for a specific finding within a scan.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan identifier.</param>
|
||||
/// <param name="findingId">The finding identifier (CVE@PURL format).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The composed evidence response, or null if the scan or finding is not found.
|
||||
/// </returns>
|
||||
Task<FindingEvidenceResponse?> GetEvidenceAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IHumanApprovalAttestationService.cs
|
||||
// Sprint: SPRINT_3801_0001_0004_human_approval_attestation (APPROVE-001)
|
||||
// Description: Interface for creating human approval attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates DSSE attestations for human approval decisions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Human approvals record decisions made by authorized personnel to
|
||||
/// accept, defer, reject, suppress, or escalate security findings.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These attestations have a 30-day default TTL to force periodic
|
||||
/// re-review of risk acceptance decisions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IHumanApprovalAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a human approval attestation.
|
||||
/// </summary>
|
||||
/// <param name="input">The approval input parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="HumanApprovalAttestationResult"/> containing the
|
||||
/// attestation statement and content-addressed attestation ID.
|
||||
/// </returns>
|
||||
Task<HumanApprovalAttestationResult> CreateAttestationAsync(
|
||||
HumanApprovalAttestationInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an existing approval attestation.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan ID.</param>
|
||||
/// <param name="findingId">The finding ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation result if found, null otherwise.</returns>
|
||||
Task<HumanApprovalAttestationResult?> GetAttestationAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active approval attestations for a scan.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of active approval attestations.</returns>
|
||||
Task<IReadOnlyList<HumanApprovalAttestationResult>> GetApprovalsByScanAsync(
|
||||
ScanId scanId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an existing approval attestation.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan ID.</param>
|
||||
/// <param name="findingId">The finding ID.</param>
|
||||
/// <param name="revokedBy">Who revoked the approval.</param>
|
||||
/// <param name="reason">Reason for revocation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if revoked, false if not found.</returns>
|
||||
Task<bool> RevokeApprovalAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
string revokedBy,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for creating a human approval attestation.
|
||||
/// </summary>
|
||||
public sealed record HumanApprovalAttestationInput
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan ID.
|
||||
/// </summary>
|
||||
public required ScanId ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The finding ID (e.g., CVE identifier).
|
||||
/// </summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approval decision.
|
||||
/// </summary>
|
||||
public required ApprovalDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's user ID.
|
||||
/// </summary>
|
||||
public required string ApproverUserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's display name.
|
||||
/// </summary>
|
||||
public string? ApproverDisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's role.
|
||||
/// </summary>
|
||||
public string? ApproverRole { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the decision.
|
||||
/// </summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom TTL for the approval.
|
||||
/// </summary>
|
||||
public TimeSpan? ApprovalTtl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy decision attestation.
|
||||
/// </summary>
|
||||
public string? PolicyDecisionRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional restrictions on the approval scope.
|
||||
/// </summary>
|
||||
public ApprovalRestrictions? Restrictions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional prior approval being superseded.
|
||||
/// </summary>
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata.
|
||||
/// </summary>
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a human approval attestation.
|
||||
/// </summary>
|
||||
public sealed record HumanApprovalAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the attestation was created successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The human approval statement.
|
||||
/// </summary>
|
||||
public HumanApprovalStatement? Statement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The content-addressed attestation ID.
|
||||
/// </summary>
|
||||
public string? AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope (if signing is enabled).
|
||||
/// </summary>
|
||||
public string? DsseEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if creation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the approval has been revoked.
|
||||
/// </summary>
|
||||
public bool IsRevoked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static HumanApprovalAttestationResult Succeeded(
|
||||
HumanApprovalStatement statement,
|
||||
string attestationId,
|
||||
string? dsseEnvelope = null)
|
||||
=> new()
|
||||
{
|
||||
Success = true,
|
||||
Statement = statement,
|
||||
AttestationId = attestationId,
|
||||
DsseEnvelope = dsseEnvelope
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static HumanApprovalAttestationResult Failed(string error)
|
||||
=> new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOfflineAttestationVerifier.cs
|
||||
// Sprint: SPRINT_3801_0002_0001_offline_verification (OV-001)
|
||||
// Description: Interface for offline/air-gap attestation chain verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies attestation chains without network access.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enables air-gap and offline verification by using bundled trust roots
|
||||
/// instead of querying transparency logs or certificate authorities.
|
||||
/// </remarks>
|
||||
public interface IOfflineAttestationVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies an attestation chain offline using bundled trust roots.
|
||||
/// </summary>
|
||||
/// <param name="chain">The attestation chain to verify.</param>
|
||||
/// <param name="trustBundle">The trust root bundle for offline verification.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<OfflineVerificationResult> VerifyOfflineAsync(
|
||||
AttestationChain chain,
|
||||
TrustRootBundle trustBundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a single DSSE envelope signature offline.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope to verify.</param>
|
||||
/// <param name="trustBundle">The trust root bundle.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signature verification result.</returns>
|
||||
Task<SignatureVerificationResult> VerifySignatureOfflineAsync(
|
||||
DsseEnvelopeData envelope,
|
||||
TrustRootBundle trustBundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a certificate chain against bundled trust roots.
|
||||
/// </summary>
|
||||
/// <param name="certificate">The certificate to validate.</param>
|
||||
/// <param name="trustBundle">The trust root bundle.</param>
|
||||
/// <param name="referenceTime">Reference time for validation (defaults to bundle timestamp).</param>
|
||||
/// <returns>The certificate validation result.</returns>
|
||||
CertificateValidationResult ValidateCertificateChain(
|
||||
X509Certificate2 certificate,
|
||||
TrustRootBundle trustBundle,
|
||||
DateTimeOffset? referenceTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trust root bundle from a directory of certificates.
|
||||
/// </summary>
|
||||
/// <param name="bundlePath">Path to the trust root bundle directory.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The loaded trust root bundle.</returns>
|
||||
Task<TrustRootBundle> LoadBundleAsync(
|
||||
string bundlePath,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of offline attestation chain verification.
|
||||
/// </summary>
|
||||
public sealed record OfflineVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the chain was successfully verified offline.
|
||||
/// </summary>
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification status for each attestation in the chain.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AttestationOfflineVerificationDetail> AttestationDetails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall chain status.
|
||||
/// </summary>
|
||||
public required OfflineChainStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time when verification was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust bundle digest used for verification.
|
||||
/// </summary>
|
||||
public string? BundleDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issues encountered during verification.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Issues { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static OfflineVerificationResult Success(
|
||||
IReadOnlyList<AttestationOfflineVerificationDetail> details,
|
||||
DateTimeOffset verifiedAt,
|
||||
string? bundleDigest = null) => new()
|
||||
{
|
||||
Verified = true,
|
||||
AttestationDetails = details,
|
||||
Status = OfflineChainStatus.Verified,
|
||||
VerifiedAt = verifiedAt,
|
||||
BundleDigest = bundleDigest,
|
||||
Issues = []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static OfflineVerificationResult Failure(
|
||||
OfflineChainStatus status,
|
||||
IReadOnlyList<AttestationOfflineVerificationDetail> details,
|
||||
DateTimeOffset verifiedAt,
|
||||
IReadOnlyList<string> issues) => new()
|
||||
{
|
||||
Verified = false,
|
||||
AttestationDetails = details,
|
||||
Status = status,
|
||||
VerifiedAt = verifiedAt,
|
||||
Issues = issues
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification detail for a single attestation in offline mode.
|
||||
/// </summary>
|
||||
public sealed record AttestationOfflineVerificationDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Attestation type.
|
||||
/// </summary>
|
||||
public required AttestationType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this attestation was verified.
|
||||
/// </summary>
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification status.
|
||||
/// </summary>
|
||||
public required SignatureVerificationResult SignatureResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate validation result (if applicable).
|
||||
/// </summary>
|
||||
public CertificateValidationResult? CertificateResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issues specific to this attestation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Issues { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offline chain verification status.
|
||||
/// </summary>
|
||||
public enum OfflineChainStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// All attestations verified successfully offline.
|
||||
/// </summary>
|
||||
Verified,
|
||||
|
||||
/// <summary>
|
||||
/// Some attestations could not be verified.
|
||||
/// </summary>
|
||||
PartiallyVerified,
|
||||
|
||||
/// <summary>
|
||||
/// No attestations could be verified.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Trust bundle is expired or invalid.
|
||||
/// </summary>
|
||||
BundleExpired,
|
||||
|
||||
/// <summary>
|
||||
/// Trust bundle is missing required certificates.
|
||||
/// </summary>
|
||||
BundleIncomplete,
|
||||
|
||||
/// <summary>
|
||||
/// Chain is empty.
|
||||
/// </summary>
|
||||
Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature was verified.
|
||||
/// </summary>
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm used for signing.
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer identity (e.g., email, URI).
|
||||
/// </summary>
|
||||
public string? SignerIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Failure reason if not verified.
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static SignatureVerificationResult Success(
|
||||
string? algorithm = null,
|
||||
string? keyId = null,
|
||||
string? signerIdentity = null) => new()
|
||||
{
|
||||
Verified = true,
|
||||
Algorithm = algorithm,
|
||||
KeyId = keyId,
|
||||
SignerIdentity = signerIdentity
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static SignatureVerificationResult Failure(string reason) => new()
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of certificate chain validation.
|
||||
/// </summary>
|
||||
public sealed record CertificateValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the certificate chain is valid.
|
||||
/// </summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate subject.
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate issuer.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate expiration time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust chain depth.
|
||||
/// </summary>
|
||||
public int ChainDepth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Failure reason if not valid.
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a valid result.
|
||||
/// </summary>
|
||||
public static CertificateValidationResult Validated(
|
||||
string subject,
|
||||
string issuer,
|
||||
DateTimeOffset expiresAt,
|
||||
int chainDepth) => new()
|
||||
{
|
||||
Valid = true,
|
||||
Subject = subject,
|
||||
Issuer = issuer,
|
||||
ExpiresAt = expiresAt,
|
||||
ChainDepth = chainDepth
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invalid result.
|
||||
/// </summary>
|
||||
public static CertificateValidationResult InvalidChain(string reason) => new()
|
||||
{
|
||||
Valid = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust root bundle for offline verification.
|
||||
/// </summary>
|
||||
public sealed record TrustRootBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Root CA certificates.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<X509Certificate2> RootCertificates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Intermediate CA certificates.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<X509Certificate2> IntermediateCertificates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trusted timestamps for time validation.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<TrustedTimestamp> TrustedTimestamps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Public keys for Rekor/transparency log verification.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<TrustedPublicKey> TransparencyLogKeys { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset BundleCreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset BundleExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the bundle.
|
||||
/// </summary>
|
||||
public string? BundleDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version identifier.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the bundle has expired.
|
||||
/// </summary>
|
||||
public bool IsExpired(DateTimeOffset referenceTime)
|
||||
=> referenceTime > BundleExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty bundle.
|
||||
/// </summary>
|
||||
public static TrustRootBundle Empty => new()
|
||||
{
|
||||
RootCertificates = [],
|
||||
IntermediateCertificates = [],
|
||||
TrustedTimestamps = [],
|
||||
TransparencyLogKeys = [],
|
||||
BundleCreatedAt = DateTimeOffset.MinValue,
|
||||
BundleExpiresAt = DateTimeOffset.MinValue
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trusted timestamp for offline time validation.
|
||||
/// </summary>
|
||||
public sealed record TrustedTimestamp
|
||||
{
|
||||
/// <summary>
|
||||
/// Timestamp value.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the timestamp (e.g., "rekor", "tsa").
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log index or sequence number.
|
||||
/// </summary>
|
||||
public long? LogIndex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trusted public key for transparency log verification.
|
||||
/// </summary>
|
||||
public sealed record TrustedPublicKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Key ID.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM-encoded public key.
|
||||
/// </summary>
|
||||
public required string PublicKeyPem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key algorithm (e.g., "ecdsa-p256", "ed25519").
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What the key is used for (e.g., "rekor", "ctfe", "fulcio").
|
||||
/// </summary>
|
||||
public required string Purpose { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key became valid.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidTo { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope data for verification.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeData
|
||||
{
|
||||
/// <summary>
|
||||
/// Payload type (e.g., "application/vnd.in-toto+json").
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures on the envelope.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DsseSignatureData> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature data.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureData
|
||||
{
|
||||
/// <summary>
|
||||
/// Key ID.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
public required string SignatureBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM-encoded certificate (for keyless signing).
|
||||
/// </summary>
|
||||
public string? CertificatePem { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IPolicyDecisionAttestationService.cs
|
||||
// Sprint: SPRINT_3801_0001_0001_policy_decision_attestation
|
||||
// Description: Service interface for creating policy decision attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating DSSE attestations for policy decisions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Policy decision attestations link findings to the evidence and rules
|
||||
/// that determined their disposition. This enables verification that
|
||||
/// approvals are evidence-linked and policy-compliant.
|
||||
/// </remarks>
|
||||
public interface IPolicyDecisionAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a policy decision attestation for a finding.
|
||||
/// </summary>
|
||||
/// <param name="input">The policy decision input data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created attestation with statement and optional DSSE envelope.</returns>
|
||||
Task<PolicyDecisionAttestationResult> CreateAttestationAsync(
|
||||
PolicyDecisionInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an existing policy decision attestation for a finding.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan identifier.</param>
|
||||
/// <param name="findingId">The finding identifier (CVE@PURL format).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation if found, null otherwise.</returns>
|
||||
Task<PolicyDecisionAttestationResult?> GetAttestationAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for creating a policy decision attestation.
|
||||
/// </summary>
|
||||
public sealed record PolicyDecisionInput
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan identifier.
|
||||
/// </summary>
|
||||
public required ScanId ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The finding identifier (CVE@PURL format).
|
||||
/// </summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The CVE identifier.
|
||||
/// </summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The component PURL.
|
||||
/// </summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The policy decision.
|
||||
/// </summary>
|
||||
public required PolicyDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision reasoning.
|
||||
/// </summary>
|
||||
public required PolicyDecisionReasoning Reasoning { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// References to evidence artifacts (digests).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> EvidenceRefs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version used for evaluation.
|
||||
/// </summary>
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the policy configuration.
|
||||
/// </summary>
|
||||
public string? PolicyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decision expiry time (defaults to 30 days from evaluation).
|
||||
/// </summary>
|
||||
public TimeSpan? DecisionTtl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a policy decision attestation.
|
||||
/// </summary>
|
||||
public sealed record PolicyDecisionAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the attestation was created successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The policy decision statement.
|
||||
/// </summary>
|
||||
public PolicyDecisionStatement? Statement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of the attestation (sha256:...).
|
||||
/// </summary>
|
||||
public string? AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded DSSE envelope (if signing was performed).
|
||||
/// </summary>
|
||||
public string? DsseEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if creation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static PolicyDecisionAttestationResult Succeeded(
|
||||
PolicyDecisionStatement statement,
|
||||
string attestationId,
|
||||
string? dsseEnvelope = null)
|
||||
=> new()
|
||||
{
|
||||
Success = true,
|
||||
Statement = statement,
|
||||
AttestationId = attestationId,
|
||||
DsseEnvelope = dsseEnvelope
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static PolicyDecisionAttestationResult Failed(string error)
|
||||
=> new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
@@ -46,4 +46,26 @@ public interface IReachabilityQueryService
|
||||
string? cveFilter,
|
||||
string? statusFilter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets reachability states for PR comparison by call graph ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, ReachabilityState>> GetReachabilityStatesAsync(
|
||||
string graphId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state for a vulnerability.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityState
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required bool IsReachable { get; init; }
|
||||
public required string ConfidenceTier { get; init; }
|
||||
public string? WitnessId { get; init; }
|
||||
public string? Entrypoint { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IRichGraphAttestationService.cs
|
||||
// Sprint: SPRINT_3801_0001_0002_richgraph_attestation
|
||||
// Description: Service interface for creating RichGraph attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating DSSE attestations for RichGraph computations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// RichGraph attestations link the computed call graph analysis to its
|
||||
/// source artifacts (SBOM, call graph) and provide content-addressed
|
||||
/// verification of the graph structure.
|
||||
/// </remarks>
|
||||
public interface IRichGraphAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a RichGraph attestation for a computed graph.
|
||||
/// </summary>
|
||||
/// <param name="input">The RichGraph attestation input data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created attestation with statement and optional DSSE envelope.</returns>
|
||||
Task<RichGraphAttestationResult> CreateAttestationAsync(
|
||||
RichGraphAttestationInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an existing RichGraph attestation.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan identifier.</param>
|
||||
/// <param name="graphId">The graph identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation if found, null otherwise.</returns>
|
||||
Task<RichGraphAttestationResult?> GetAttestationAsync(
|
||||
ScanId scanId,
|
||||
string graphId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for creating a RichGraph attestation.
|
||||
/// </summary>
|
||||
public sealed record RichGraphAttestationInput
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan identifier.
|
||||
/// </summary>
|
||||
public required ScanId ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The RichGraph identifier.
|
||||
/// </summary>
|
||||
public required string GraphId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the RichGraph.
|
||||
/// </summary>
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of nodes in the graph.
|
||||
/// </summary>
|
||||
public required int NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of edges in the graph.
|
||||
/// </summary>
|
||||
public required int EdgeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of root nodes (entrypoints).
|
||||
/// </summary>
|
||||
public required int RootCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer name.
|
||||
/// </summary>
|
||||
public required string AnalyzerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer version.
|
||||
/// </summary>
|
||||
public required string AnalyzerVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer configuration hash.
|
||||
/// </summary>
|
||||
public string? AnalyzerConfigHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the source SBOM (digest).
|
||||
/// </summary>
|
||||
public string? SbomRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the source call graph (digest).
|
||||
/// </summary>
|
||||
public string? CallgraphRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Language of the analyzed code.
|
||||
/// </summary>
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// TTL for the graph attestation (defaults to 7 days).
|
||||
/// </summary>
|
||||
public TimeSpan? GraphTtl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a RichGraph attestation.
|
||||
/// </summary>
|
||||
public sealed record RichGraphAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the attestation was created successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The RichGraph statement.
|
||||
/// </summary>
|
||||
public RichGraphStatement? Statement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of the attestation (sha256:...).
|
||||
/// </summary>
|
||||
public string? AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded DSSE envelope (if signing was performed).
|
||||
/// </summary>
|
||||
public string? DsseEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if creation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static RichGraphAttestationResult Succeeded(
|
||||
RichGraphStatement statement,
|
||||
string attestationId,
|
||||
string? dsseEnvelope = null)
|
||||
=> new()
|
||||
{
|
||||
Success = true,
|
||||
Statement = statement,
|
||||
AttestationId = attestationId,
|
||||
DsseEnvelope = dsseEnvelope
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static RichGraphAttestationResult Failed(string error)
|
||||
=> new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,12 @@ internal sealed class NullReachabilityQueryService : IReachabilityQueryService
|
||||
string? statusFilter,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<ReachabilityFinding>>(Array.Empty<ReachabilityFinding>());
|
||||
|
||||
public Task<IReadOnlyDictionary<string, ReachabilityState>> GetReachabilityStatesAsync(
|
||||
string graphId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyDictionary<string, ReachabilityState>>(
|
||||
new Dictionary<string, ReachabilityState>());
|
||||
}
|
||||
|
||||
internal sealed class NullReachabilityExplainService : IReachabilityExplainService
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineAttestationVerifier.cs
|
||||
// Sprint: SPRINT_3801_0002_0001_offline_verification (OV-001..OV-004)
|
||||
// Description: Verifies attestation chains without network access.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies attestation chains offline using bundled trust roots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enables air-gap operation by:
|
||||
/// <list type="bullet">
|
||||
/// <item>Validating DSSE signatures against bundled public keys</item>
|
||||
/// <item>Verifying certificate chains against bundled root/intermediate CAs</item>
|
||||
/// <item>Checking timestamps against bundled trusted timestamps</item>
|
||||
/// <item>Supporting Rekor inclusion proofs via offline receipts</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class OfflineAttestationVerifier : IOfflineAttestationVerifier
|
||||
{
|
||||
private readonly ILogger<OfflineAttestationVerifier> _logger;
|
||||
private readonly OfflineVerifierOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="OfflineAttestationVerifier"/>.
|
||||
/// </summary>
|
||||
public OfflineAttestationVerifier(
|
||||
ILogger<OfflineAttestationVerifier> logger,
|
||||
IOptions<OfflineVerifierOptions> 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 async Task<OfflineVerificationResult> VerifyOfflineAsync(
|
||||
AttestationChain chain,
|
||||
TrustRootBundle trustBundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chain);
|
||||
ArgumentNullException.ThrowIfNull(trustBundle);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Starting offline verification for chain {ChainId} with {Count} attestations",
|
||||
chain.ChainId,
|
||||
chain.Attestations.Count);
|
||||
|
||||
// Check bundle expiry
|
||||
if (trustBundle.IsExpired(now))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Trust bundle expired at {ExpiresAt}, current time {Now}",
|
||||
trustBundle.BundleExpiresAt,
|
||||
now);
|
||||
|
||||
return OfflineVerificationResult.Failure(
|
||||
OfflineChainStatus.BundleExpired,
|
||||
[],
|
||||
now,
|
||||
[$"Trust bundle expired at {trustBundle.BundleExpiresAt:O}"]);
|
||||
}
|
||||
|
||||
// Validate bundle has required components
|
||||
var bundleIssues = ValidateBundleCompleteness(trustBundle);
|
||||
if (bundleIssues.Count > 0)
|
||||
{
|
||||
_logger.LogWarning("Trust bundle incomplete: {Issues}", string.Join(", ", bundleIssues));
|
||||
|
||||
return OfflineVerificationResult.Failure(
|
||||
OfflineChainStatus.BundleIncomplete,
|
||||
[],
|
||||
now,
|
||||
bundleIssues);
|
||||
}
|
||||
|
||||
// Empty chain check
|
||||
if (chain.Attestations.Count == 0)
|
||||
{
|
||||
return OfflineVerificationResult.Failure(
|
||||
OfflineChainStatus.Empty,
|
||||
[],
|
||||
now,
|
||||
["Attestation chain is empty"]);
|
||||
}
|
||||
|
||||
// Verify each attestation
|
||||
var details = new List<AttestationOfflineVerificationDetail>();
|
||||
var allIssues = new List<string>();
|
||||
var hasFailures = false;
|
||||
|
||||
foreach (var attestation in chain.Attestations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var detail = await VerifyAttestationOfflineAsync(
|
||||
attestation,
|
||||
trustBundle,
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
details.Add(detail);
|
||||
|
||||
if (!detail.Verified)
|
||||
{
|
||||
hasFailures = true;
|
||||
allIssues.AddRange(detail.Issues);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Offline verification completed for chain {ChainId}: {Status} in {ElapsedMs}ms",
|
||||
chain.ChainId,
|
||||
hasFailures ? "PartiallyVerified" : "Verified",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
|
||||
if (hasFailures)
|
||||
{
|
||||
var verifiedCount = details.Count(d => d.Verified);
|
||||
var status = verifiedCount > 0
|
||||
? OfflineChainStatus.PartiallyVerified
|
||||
: OfflineChainStatus.Failed;
|
||||
|
||||
return OfflineVerificationResult.Failure(
|
||||
status,
|
||||
details,
|
||||
now,
|
||||
allIssues);
|
||||
}
|
||||
|
||||
return OfflineVerificationResult.Success(
|
||||
details,
|
||||
now,
|
||||
trustBundle.BundleDigest);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SignatureVerificationResult> VerifySignatureOfflineAsync(
|
||||
DsseEnvelopeData envelope,
|
||||
TrustRootBundle trustBundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(trustBundle);
|
||||
|
||||
if (envelope.Signatures.Count == 0)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("No signatures in envelope");
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(envelope.PayloadBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Invalid base64 payload");
|
||||
}
|
||||
|
||||
// Compute PAE (Pre-Authentication Encoding) per DSSE spec
|
||||
var pae = ComputePae(envelope.PayloadType, payloadBytes);
|
||||
|
||||
// Try to verify at least one signature
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await VerifySingleSignatureAsync(sig, pae, trustBundle, cancellationToken);
|
||||
if (result.Verified)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return SignatureVerificationResult.Failure(
|
||||
$"None of {envelope.Signatures.Count} signatures could be verified");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CertificateValidationResult ValidateCertificateChain(
|
||||
X509Certificate2 certificate,
|
||||
TrustRootBundle trustBundle,
|
||||
DateTimeOffset? referenceTime = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(certificate);
|
||||
ArgumentNullException.ThrowIfNull(trustBundle);
|
||||
|
||||
var refTime = referenceTime ?? trustBundle.BundleCreatedAt;
|
||||
|
||||
try
|
||||
{
|
||||
using var chain = new X509Chain();
|
||||
|
||||
// Configure for offline validation
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
chain.ChainPolicy.VerificationTime = refTime.DateTime;
|
||||
|
||||
// Add trust roots
|
||||
foreach (var root in trustBundle.RootCertificates)
|
||||
{
|
||||
chain.ChainPolicy.CustomTrustStore.Add(root);
|
||||
}
|
||||
|
||||
// Add intermediates
|
||||
foreach (var intermediate in trustBundle.IntermediateCertificates)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(intermediate);
|
||||
}
|
||||
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
|
||||
var isValid = chain.Build(certificate);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
var statusMessages = chain.ChainStatus
|
||||
.Select(s => s.StatusInformation)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.ToList();
|
||||
|
||||
return CertificateValidationResult.InvalidChain(
|
||||
string.Join("; ", statusMessages.Count > 0 ? statusMessages : ["Chain build failed"]));
|
||||
}
|
||||
|
||||
return CertificateValidationResult.Validated(
|
||||
subject: certificate.Subject,
|
||||
issuer: certificate.Issuer,
|
||||
expiresAt: certificate.NotAfter,
|
||||
chainDepth: chain.ChainElements.Count);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Certificate validation failed for {Subject}", certificate.Subject);
|
||||
return CertificateValidationResult.InvalidChain($"Cryptographic error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustRootBundle> LoadBundleAsync(
|
||||
string bundlePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
if (!Directory.Exists(bundlePath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Trust bundle directory not found: {bundlePath}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loading trust bundle from {Path}", bundlePath);
|
||||
|
||||
var roots = new List<X509Certificate2>();
|
||||
var intermediates = new List<X509Certificate2>();
|
||||
var timestamps = new List<TrustedTimestamp>();
|
||||
var publicKeys = new List<TrustedPublicKey>();
|
||||
var bundleCreatedAt = DateTimeOffset.MinValue;
|
||||
var bundleExpiresAt = DateTimeOffset.MaxValue;
|
||||
string? bundleVersion = null;
|
||||
|
||||
// Load root certificates
|
||||
var rootsPath = Path.Combine(bundlePath, "roots");
|
||||
if (Directory.Exists(rootsPath))
|
||||
{
|
||||
foreach (var certFile in Directory.EnumerateFiles(rootsPath, "*.pem"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var pemText = await File.ReadAllTextAsync(certFile, cancellationToken);
|
||||
var cert = LoadCertificateFromPem(pemText);
|
||||
if (cert != null)
|
||||
{
|
||||
roots.Add(cert);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load root certificate: {File}", certFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load intermediate certificates
|
||||
var intermediatesPath = Path.Combine(bundlePath, "intermediates");
|
||||
if (Directory.Exists(intermediatesPath))
|
||||
{
|
||||
foreach (var certFile in Directory.EnumerateFiles(intermediatesPath, "*.pem"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var pemText = await File.ReadAllTextAsync(certFile, cancellationToken);
|
||||
var cert = LoadCertificateFromPem(pemText);
|
||||
if (cert != null)
|
||||
{
|
||||
intermediates.Add(cert);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load intermediate certificate: {File}", certFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load transparency log public keys
|
||||
var keysPath = Path.Combine(bundlePath, "keys");
|
||||
if (Directory.Exists(keysPath))
|
||||
{
|
||||
foreach (var keyFile in Directory.EnumerateFiles(keysPath, "*.pem"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var keyPem = await File.ReadAllTextAsync(keyFile, cancellationToken);
|
||||
var keyId = Path.GetFileNameWithoutExtension(keyFile);
|
||||
publicKeys.Add(new TrustedPublicKey
|
||||
{
|
||||
KeyId = keyId,
|
||||
PublicKeyPem = keyPem,
|
||||
Algorithm = InferKeyAlgorithm(keyPem),
|
||||
Purpose = InferKeyPurpose(keyId)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load public key: {File}", keyFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load bundle metadata
|
||||
var metadataPath = Path.Combine(bundlePath, "bundle.json");
|
||||
if (File.Exists(metadataPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadataJson = await File.ReadAllTextAsync(metadataPath, cancellationToken);
|
||||
var metadata = JsonSerializer.Deserialize<BundleMetadata>(metadataJson, JsonOptions);
|
||||
if (metadata != null)
|
||||
{
|
||||
if (metadata.CreatedAt.HasValue)
|
||||
bundleCreatedAt = metadata.CreatedAt.Value;
|
||||
if (metadata.ExpiresAt.HasValue)
|
||||
bundleExpiresAt = metadata.ExpiresAt.Value;
|
||||
bundleVersion = metadata.Version;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load bundle metadata: {File}", metadataPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute bundle digest
|
||||
var bundleDigest = await ComputeBundleDigestAsync(bundlePath, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded trust bundle: {Roots} roots, {Intermediates} intermediates, {Keys} keys, version {Version}",
|
||||
roots.Count,
|
||||
intermediates.Count,
|
||||
publicKeys.Count,
|
||||
bundleVersion ?? "unknown");
|
||||
|
||||
return new TrustRootBundle
|
||||
{
|
||||
RootCertificates = roots.ToImmutableList(),
|
||||
IntermediateCertificates = intermediates.ToImmutableList(),
|
||||
TrustedTimestamps = timestamps.ToImmutableList(),
|
||||
TransparencyLogKeys = publicKeys.ToImmutableList(),
|
||||
BundleCreatedAt = bundleCreatedAt,
|
||||
BundleExpiresAt = bundleExpiresAt,
|
||||
BundleDigest = bundleDigest,
|
||||
Version = bundleVersion
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Methods
|
||||
// =========================================================================
|
||||
|
||||
private async Task<AttestationOfflineVerificationDetail> VerifyAttestationOfflineAsync(
|
||||
ChainAttestation attestation,
|
||||
TrustRootBundle trustBundle,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
|
||||
// For offline verification, we work with the attestation's existing verification status
|
||||
// and verify against the trust bundle.
|
||||
// The actual DSSE envelope content would typically be fetched from storage.
|
||||
|
||||
// Check if attestation was already verified online
|
||||
if (attestation.VerificationStatus == AttestationVerificationStatus.Valid)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Attestation {Id} already verified online, status: {Status}",
|
||||
attestation.AttestationId,
|
||||
attestation.VerificationStatus);
|
||||
}
|
||||
|
||||
// Create signature result based on verification status
|
||||
var sigResult = attestation.Verified
|
||||
? SignatureVerificationResult.Success(algorithm: "offline-trusted")
|
||||
: SignatureVerificationResult.Failure(attestation.Error ?? "Not verified");
|
||||
|
||||
CertificateValidationResult? certResult = null;
|
||||
|
||||
// Check expiration
|
||||
if (attestation.ExpiresAt < now)
|
||||
{
|
||||
issues.Add($"Attestation expired at {attestation.ExpiresAt:O}");
|
||||
}
|
||||
|
||||
// Check verification status
|
||||
switch (attestation.VerificationStatus)
|
||||
{
|
||||
case AttestationVerificationStatus.Expired:
|
||||
issues.Add("Attestation has expired");
|
||||
break;
|
||||
case AttestationVerificationStatus.InvalidSignature:
|
||||
issues.Add("Attestation signature is invalid");
|
||||
break;
|
||||
case AttestationVerificationStatus.NotFound:
|
||||
issues.Add("Attestation was not found");
|
||||
break;
|
||||
case AttestationVerificationStatus.ChainBroken:
|
||||
issues.Add("Attestation chain is broken");
|
||||
break;
|
||||
case AttestationVerificationStatus.Pending:
|
||||
issues.Add("Attestation verification is pending");
|
||||
break;
|
||||
}
|
||||
|
||||
var verified = attestation.Verified &&
|
||||
attestation.VerificationStatus == AttestationVerificationStatus.Valid &&
|
||||
attestation.ExpiresAt >= now &&
|
||||
issues.Count == 0;
|
||||
|
||||
// For offline mode, we trust the existing verification if valid
|
||||
// In full offline mode, we would verify DSSE signatures against bundle keys
|
||||
await Task.CompletedTask; // Placeholder for async signature verification
|
||||
|
||||
return new AttestationOfflineVerificationDetail
|
||||
{
|
||||
Type = attestation.Type,
|
||||
Verified = verified,
|
||||
SignatureResult = sigResult,
|
||||
CertificateResult = certResult,
|
||||
Issues = issues.ToImmutableList()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SignatureVerificationResult> VerifySingleSignatureAsync(
|
||||
DsseSignatureData signature,
|
||||
byte[] pae,
|
||||
TrustRootBundle trustBundle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Decode signature
|
||||
byte[] sigBytes;
|
||||
try
|
||||
{
|
||||
sigBytes = Convert.FromBase64String(signature.SignatureBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Invalid base64 signature");
|
||||
}
|
||||
|
||||
// Try certificate-based verification first (keyless)
|
||||
if (!string.IsNullOrEmpty(signature.CertificatePem))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cert = X509Certificate2.CreateFromPem(signature.CertificatePem);
|
||||
using var publicKey = cert.GetECDsaPublicKey() ?? cert.GetRSAPublicKey() as AsymmetricAlgorithm;
|
||||
|
||||
if (publicKey is ECDsa ecdsa)
|
||||
{
|
||||
if (ecdsa.VerifyData(pae, sigBytes, HashAlgorithmName.SHA256))
|
||||
{
|
||||
return SignatureVerificationResult.Success(
|
||||
algorithm: "ECDSA-P256",
|
||||
keyId: signature.KeyId,
|
||||
signerIdentity: ExtractSignerIdentity(cert));
|
||||
}
|
||||
}
|
||||
else if (publicKey is RSA rsa)
|
||||
{
|
||||
if (rsa.VerifyData(pae, sigBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
|
||||
{
|
||||
return SignatureVerificationResult.Success(
|
||||
algorithm: "RSA-SHA256",
|
||||
keyId: signature.KeyId,
|
||||
signerIdentity: ExtractSignerIdentity(cert));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Certificate-based verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Try key ID-based verification
|
||||
if (!string.IsNullOrEmpty(signature.KeyId))
|
||||
{
|
||||
var trustedKey = trustBundle.TransparencyLogKeys
|
||||
.FirstOrDefault(k => string.Equals(k.KeyId, signature.KeyId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (trustedKey != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var verified = VerifyWithPublicKey(trustedKey.PublicKeyPem, pae, sigBytes);
|
||||
if (verified)
|
||||
{
|
||||
return SignatureVerificationResult.Success(
|
||||
algorithm: trustedKey.Algorithm,
|
||||
keyId: trustedKey.KeyId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Key-based verification failed for {KeyId}", signature.KeyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SignatureVerificationResult.Failure("Signature verification failed");
|
||||
}
|
||||
|
||||
private static bool VerifyWithPublicKey(string publicKeyPem, byte[] data, byte[] signature)
|
||||
{
|
||||
// Try ECDSA first
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportFromPem(publicKeyPem);
|
||||
return ecdsa.VerifyData(data, signature, HashAlgorithmName.SHA256);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try RSA
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
return rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try Ed25519 via NSec or similar if available
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputePae(string payloadType, byte[] payload)
|
||||
{
|
||||
// Pre-Authentication Encoding per DSSE spec:
|
||||
// PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
|
||||
const string DssePrefix = "DSSEv1";
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
writer.Write(Encoding.UTF8.GetBytes(DssePrefix));
|
||||
writer.Write((byte)' ');
|
||||
writer.Write(BitConverter.GetBytes((long)typeBytes.Length));
|
||||
writer.Write((byte)' ');
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
writer.Write(BitConverter.GetBytes((long)payload.Length));
|
||||
writer.Write((byte)' ');
|
||||
writer.Write(payload);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static string? ExtractSignerIdentity(X509Certificate2 cert)
|
||||
{
|
||||
// Try to get SAN (Subject Alternative Name) email
|
||||
foreach (var ext in cert.Extensions)
|
||||
{
|
||||
if (ext.Oid?.Value == "2.5.29.17") // SAN
|
||||
{
|
||||
var san = new AsnEncodedData(ext.Oid, ext.RawData);
|
||||
var sanString = san.Format(true);
|
||||
// Look for email or URI
|
||||
var lines = sanString.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.Contains("RFC822", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("email", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = line.Split([':', '='], 2);
|
||||
if (parts.Length > 1)
|
||||
return parts[1].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cert.Subject;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidateBundleCompleteness(TrustRootBundle bundle)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
|
||||
if (bundle.RootCertificates.Count == 0 && bundle.TransparencyLogKeys.Count == 0)
|
||||
{
|
||||
issues.Add("Bundle must contain at least one root certificate or public key");
|
||||
}
|
||||
|
||||
if (bundle.BundleCreatedAt == DateTimeOffset.MinValue)
|
||||
{
|
||||
issues.Add("Bundle creation time is not set");
|
||||
}
|
||||
|
||||
if (bundle.BundleExpiresAt == DateTimeOffset.MinValue ||
|
||||
bundle.BundleExpiresAt == DateTimeOffset.MaxValue)
|
||||
{
|
||||
issues.Add("Bundle expiration time is not set");
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static string InferKeyAlgorithm(string keyPem)
|
||||
{
|
||||
if (keyPem.Contains("EC PRIVATE KEY") || keyPem.Contains("EC PUBLIC KEY"))
|
||||
return "ecdsa-p256";
|
||||
if (keyPem.Contains("RSA"))
|
||||
return "rsa";
|
||||
if (keyPem.Contains("ED25519"))
|
||||
return "ed25519";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string InferKeyPurpose(string keyId)
|
||||
{
|
||||
var lower = keyId.ToLowerInvariant();
|
||||
if (lower.Contains("rekor")) return "rekor";
|
||||
if (lower.Contains("ctfe")) return "ctfe";
|
||||
if (lower.Contains("fulcio")) return "fulcio";
|
||||
if (lower.Contains("tsa")) return "tsa";
|
||||
return "general";
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeBundleDigestAsync(
|
||||
string bundlePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
// Hash all files in sorted order for determinism
|
||||
var files = Directory.EnumerateFiles(bundlePath, "*", SearchOption.AllDirectories)
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var relativePath = Path.GetRelativePath(bundlePath, file);
|
||||
var pathBytes = Encoding.UTF8.GetBytes(relativePath);
|
||||
await ms.WriteAsync(pathBytes, cancellationToken);
|
||||
|
||||
var fileBytes = await File.ReadAllBytesAsync(file, cancellationToken);
|
||||
await ms.WriteAsync(fileBytes, cancellationToken);
|
||||
}
|
||||
|
||||
ms.Position = 0;
|
||||
var hash = await sha256.ComputeHashAsync(ms, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class BundleMetadata
|
||||
{
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public string? Version { get; set; }
|
||||
}
|
||||
|
||||
private static X509Certificate2? LoadCertificateFromPem(string pemText)
|
||||
{
|
||||
// Extract the base64 content between BEGIN/END markers
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var startIndex = pemText.IndexOf(beginMarker, StringComparison.Ordinal);
|
||||
var endIndex = pemText.IndexOf(endMarker, StringComparison.Ordinal);
|
||||
|
||||
if (startIndex < 0 || endIndex < 0 || endIndex <= startIndex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var base64Start = startIndex + beginMarker.Length;
|
||||
var base64Content = pemText[base64Start..endIndex]
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Trim();
|
||||
|
||||
var certBytes = Convert.FromBase64String(base64Content);
|
||||
return new X509Certificate2(certBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for offline attestation verification.
|
||||
/// </summary>
|
||||
public sealed class OfflineVerifierOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default trust bundle path.
|
||||
/// </summary>
|
||||
public string? DefaultBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow verification without signature if bundle permits.
|
||||
/// </summary>
|
||||
public bool AllowUnsignedInBundle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age of bundle before warning.
|
||||
/// </summary>
|
||||
public TimeSpan BundleAgeWarningThreshold { get; set; } = TimeSpan.FromDays(30);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -518,18 +518,3 @@ public sealed class PrAnnotationService : IPrAnnotationService
|
||||
return purl[..47] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state for a vulnerability (used by annotation service).
|
||||
/// </summary>
|
||||
public sealed record ReachabilityState
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required bool IsReachable { get; init; }
|
||||
public required string ConfidenceTier { get; init; }
|
||||
public string? WitnessId { get; init; }
|
||||
public string? Entrypoint { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RichGraphAttestationService.cs
|
||||
// Sprint: SPRINT_3801_0001_0002_richgraph_attestation
|
||||
// Description: Implementation of RichGraph 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 RichGraph attestation service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creates in-toto statements for RichGraph computations. The actual DSSE signing
|
||||
/// is deferred to the Attestor module when available.
|
||||
/// </remarks>
|
||||
public sealed class RichGraphAttestationService : IRichGraphAttestationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ILogger<RichGraphAttestationService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RichGraphAttestationOptions _options;
|
||||
|
||||
// In-memory store for attestations (production would use persistent storage)
|
||||
private readonly ConcurrentDictionary<string, RichGraphAttestationResult> _attestations = new();
|
||||
|
||||
public RichGraphAttestationService(
|
||||
ILogger<RichGraphAttestationService> logger,
|
||||
IOptions<RichGraphAttestationOptions>? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_options = options?.Value ?? new RichGraphAttestationOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RichGraphAttestationResult> CreateAttestationAsync(
|
||||
RichGraphAttestationInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.GraphId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.GraphDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.AnalyzerName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.AnalyzerVersion);
|
||||
|
||||
try
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var ttl = input.GraphTtl ?? TimeSpan.FromDays(_options.DefaultGraphTtlDays);
|
||||
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.GraphId);
|
||||
var result = RichGraphAttestationResult.Succeeded(
|
||||
statement,
|
||||
attestationId,
|
||||
dsseEnvelope: null // Signing deferred to Attestor module
|
||||
);
|
||||
|
||||
_attestations[key] = result;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created RichGraph attestation for graph {GraphId}: nodes={NodeCount}, edges={EdgeCount}, attestation={AttestationId}",
|
||||
input.GraphId,
|
||||
input.NodeCount,
|
||||
input.EdgeCount,
|
||||
attestationId);
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create RichGraph attestation for {GraphId}", input.GraphId);
|
||||
return Task.FromResult(RichGraphAttestationResult.Failed(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RichGraphAttestationResult?> GetAttestationAsync(
|
||||
ScanId scanId,
|
||||
string graphId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(scanId, graphId);
|
||||
if (_attestations.TryGetValue(key, out var result))
|
||||
{
|
||||
return Task.FromResult<RichGraphAttestationResult?>(result);
|
||||
}
|
||||
|
||||
return Task.FromResult<RichGraphAttestationResult?>(null);
|
||||
}
|
||||
|
||||
private RichGraphStatement BuildStatement(
|
||||
RichGraphAttestationInput input,
|
||||
DateTimeOffset computedAt,
|
||||
DateTimeOffset expiresAt)
|
||||
{
|
||||
// Build subjects - the scan and graph are the subjects of this attestation
|
||||
var subjects = new List<RichGraphSubject>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = $"scan:{input.ScanId.Value}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ComputeSha256(input.ScanId.Value)
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = $"graph:{input.GraphId}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ExtractDigestValue(input.GraphDigest)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build predicate
|
||||
var predicate = new RichGraphPredicate
|
||||
{
|
||||
GraphId = input.GraphId,
|
||||
GraphDigest = input.GraphDigest,
|
||||
NodeCount = input.NodeCount,
|
||||
EdgeCount = input.EdgeCount,
|
||||
RootCount = input.RootCount,
|
||||
Analyzer = new RichGraphAnalyzerInfo
|
||||
{
|
||||
Name = input.AnalyzerName,
|
||||
Version = input.AnalyzerVersion,
|
||||
ConfigHash = input.AnalyzerConfigHash
|
||||
},
|
||||
ComputedAt = computedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
SbomRef = input.SbomRef,
|
||||
CallgraphRef = input.CallgraphRef,
|
||||
Language = input.Language
|
||||
};
|
||||
|
||||
return new RichGraphStatement
|
||||
{
|
||||
Subject = subjects,
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeAttestationId(RichGraphStatement 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 ExtractDigestValue(string digest)
|
||||
{
|
||||
// Handle "sha256:abc123" format
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return digest[7..];
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
private static string BuildKey(ScanId scanId, string graphId)
|
||||
=> $"{scanId.Value}:{graphId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for RichGraph attestations.
|
||||
/// </summary>
|
||||
public sealed class RichGraphAttestationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default TTL for RichGraph attestations in days.
|
||||
/// </summary>
|
||||
public int DefaultGraphTtlDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable DSSE signing when Attestor is available.
|
||||
/// </summary>
|
||||
public bool EnableSigning { get; set; } = true;
|
||||
}
|
||||
Reference in New Issue
Block a user