feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -0,0 +1,177 @@
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Risk Verdict Attestation - the signed, replayable output of policy evaluation.
/// This is the formal contract for communicating risk decisions.
/// </summary>
public sealed record RiskVerdictAttestation
{
/// <summary>
/// Unique identifier for this attestation.
/// Format: rva:{sha256-of-content}
/// </summary>
public required string AttestationId { get; init; }
/// <summary>
/// Schema version for forward compatibility.
/// </summary>
public string SchemaVersion { get; init; } = "1.0";
/// <summary>
/// When this attestation was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// The final verdict status.
/// </summary>
public required RiskVerdictStatus Verdict { get; init; }
/// <summary>
/// Subject artifact being evaluated.
/// </summary>
public required ArtifactSubject Subject { get; init; }
/// <summary>
/// Reference to the policy that was evaluated.
/// </summary>
public required RvaPolicyRef Policy { get; init; }
/// <summary>
/// Reference to the knowledge snapshot used.
/// Enables replay with frozen inputs.
/// </summary>
public required string KnowledgeSnapshotId { get; init; }
/// <summary>
/// Evidence references supporting the verdict.
/// </summary>
public IReadOnlyList<RvaEvidenceRef> Evidence { get; init; } = [];
/// <summary>
/// Reason codes explaining the verdict.
/// </summary>
public IReadOnlyList<VerdictReasonCode> ReasonCodes { get; init; } = [];
/// <summary>
/// Summary of unknowns encountered.
/// </summary>
public UnknownsSummary? Unknowns { get; init; }
/// <summary>
/// Exception IDs that were applied.
/// </summary>
public IReadOnlyList<string> AppliedExceptions { get; init; } = [];
/// <summary>
/// Human-readable explanation of the verdict.
/// </summary>
public string? Explanation { get; init; }
/// <summary>
/// Expiration time for this verdict (optional).
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Metadata for extensibility.
/// </summary>
public IReadOnlyDictionary<string, string> Metadata { get; init; }
= new Dictionary<string, string>();
}
/// <summary>
/// The four possible verdict outcomes.
/// </summary>
public enum RiskVerdictStatus
{
/// <summary>
/// No policy violations detected. Safe to proceed.
/// </summary>
Pass,
/// <summary>
/// Policy violations detected. Block deployment.
/// </summary>
Fail,
/// <summary>
/// Violations exist but are covered by approved exceptions.
/// </summary>
PassWithExceptions,
/// <summary>
/// Cannot determine risk due to insufficient data.
/// </summary>
Indeterminate
}
/// <summary>
/// The artifact being evaluated.
/// </summary>
public sealed record ArtifactSubject
{
/// <summary>
/// Artifact digest (sha256:...).
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Artifact type: container-image, sbom, binary, etc.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Human-readable name (e.g., image:tag).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Registry or repository URI.
/// </summary>
public string? Uri { get; init; }
}
/// <summary>
/// Reference to the evaluated policy.
/// </summary>
public sealed record RvaPolicyRef
{
public required string PolicyId { get; init; }
public required string Version { get; init; }
public required string Digest { get; init; }
public string? Uri { get; init; }
}
/// <summary>
/// Reference to evidence supporting the verdict.
/// </summary>
public sealed record RvaEvidenceRef
{
public required string Type { get; init; }
public required string Digest { get; init; }
public string? Uri { get; init; }
public string? Description { get; init; }
}
/// <summary>
/// Summary of unknowns encountered during evaluation.
/// </summary>
public sealed record UnknownsSummary
{
/// <summary>
/// Total number of unknowns.
/// </summary>
public int Total { get; init; }
/// <summary>
/// Number of blocking unknowns.
/// </summary>
public int BlockingCount { get; init; }
/// <summary>
/// Breakdown by unknown type.
/// </summary>
public IReadOnlyDictionary<string, int> ByType { get; init; }
= new Dictionary<string, int>();
}

View File

@@ -0,0 +1,224 @@
using System.Text.Json;
using StellaOps.Cryptography;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Fluent builder for constructing Risk Verdict Attestations.
/// </summary>
public sealed class RvaBuilder
{
private RiskVerdictStatus _verdict;
private ArtifactSubject? _subject;
private RvaPolicyRef? _policy;
private string? _snapshotId;
private readonly List<RvaEvidenceRef> _evidence = [];
private readonly List<VerdictReasonCode> _reasonCodes = [];
private readonly List<string> _exceptions = [];
private UnknownsSummary? _unknowns;
private string? _explanation;
private DateTimeOffset? _expiresAt;
private readonly Dictionary<string, string> _metadata = [];
private readonly ICryptoHash _cryptoHash;
public RvaBuilder(ICryptoHash cryptoHash)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
}
public RvaBuilder WithVerdict(RiskVerdictStatus verdict)
{
_verdict = verdict;
return this;
}
public RvaBuilder WithSubject(string digest, string type, string? name = null, string? uri = null)
{
_subject = new ArtifactSubject
{
Digest = digest,
Type = type,
Name = name,
Uri = uri
};
return this;
}
public RvaBuilder WithSubject(ArtifactSubject subject)
{
_subject = subject;
return this;
}
public RvaBuilder WithPolicy(string policyId, string version, string digest, string? uri = null)
{
_policy = new RvaPolicyRef
{
PolicyId = policyId,
Version = version,
Digest = digest,
Uri = uri
};
return this;
}
public RvaBuilder WithPolicy(RvaPolicyRef policy)
{
_policy = policy;
return this;
}
public RvaBuilder WithKnowledgeSnapshot(string snapshotId)
{
_snapshotId = snapshotId;
return this;
}
public RvaBuilder WithEvidence(string type, string digest, string? uri = null, string? description = null)
{
_evidence.Add(new RvaEvidenceRef
{
Type = type,
Digest = digest,
Uri = uri,
Description = description
});
return this;
}
public RvaBuilder WithEvidence(RvaEvidenceRef evidence)
{
_evidence.Add(evidence);
return this;
}
public RvaBuilder WithReasonCode(VerdictReasonCode code)
{
if (!_reasonCodes.Contains(code))
_reasonCodes.Add(code);
return this;
}
public RvaBuilder WithReasonCodes(IEnumerable<VerdictReasonCode> codes)
{
foreach (var code in codes)
WithReasonCode(code);
return this;
}
public RvaBuilder WithException(string exceptionId)
{
_exceptions.Add(exceptionId);
return this;
}
public RvaBuilder WithExceptions(IEnumerable<string> exceptionIds)
{
foreach (var id in exceptionIds)
WithException(id);
return this;
}
public RvaBuilder WithUnknowns(UnknownsSummary unknowns)
{
_unknowns = unknowns;
return this;
}
public RvaBuilder WithUnknowns(int total, int blockingCount)
{
_unknowns = new UnknownsSummary
{
Total = total,
BlockingCount = blockingCount
};
return this;
}
public RvaBuilder WithExplanation(string explanation)
{
_explanation = explanation;
return this;
}
public RvaBuilder WithExpiration(DateTimeOffset expiresAt)
{
_expiresAt = expiresAt;
return this;
}
public RvaBuilder WithMetadata(string key, string value)
{
_metadata[key] = value;
return this;
}
public RiskVerdictAttestation Build()
{
if (_subject is null)
throw new InvalidOperationException("Subject is required");
if (_policy is null)
throw new InvalidOperationException("Policy is required");
if (_snapshotId is null)
throw new InvalidOperationException("Knowledge snapshot ID is required");
var createdAt = DateTimeOffset.UtcNow;
var attestation = new RiskVerdictAttestation
{
AttestationId = "", // Computed below
CreatedAt = createdAt,
Verdict = _verdict,
Subject = _subject,
Policy = _policy,
KnowledgeSnapshotId = _snapshotId,
Evidence = _evidence.ToList(),
ReasonCodes = _reasonCodes.ToList(),
AppliedExceptions = _exceptions.ToList(),
Unknowns = _unknowns,
Explanation = _explanation ?? GenerateExplanation(),
ExpiresAt = _expiresAt,
Metadata = _metadata.ToDictionary()
};
// Compute content-addressed ID
var attestationId = ComputeAttestationId(attestation);
return attestation with { AttestationId = attestationId };
}
private string ComputeAttestationId(RiskVerdictAttestation attestation)
{
var json = JsonSerializer.Serialize(attestation with { AttestationId = "" },
RvaSerializerOptions.Canonical);
var hash = _cryptoHash.ComputeHashHex(System.Text.Encoding.UTF8.GetBytes(json), "SHA256");
return $"rva:sha256:{hash}";
}
private string GenerateExplanation()
{
if (_reasonCodes.Count == 0)
return $"Verdict: {_verdict}";
var reasons = string.Join(", ", _reasonCodes.Take(3).Select(c => c.GetDescription()));
return $"Verdict: {_verdict}. Reasons: {reasons}";
}
}
/// <summary>
/// Centralized JSON serializer options for RVA.
/// </summary>
internal static class RvaSerializerOptions
{
/// <summary>
/// Canonical JSON options for deterministic serialization.
/// </summary>
public static JsonSerializerOptions Canonical { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
}

View File

@@ -0,0 +1,187 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// In-toto predicate wrapper for Risk Verdict Attestations.
/// </summary>
public static class RvaPredicate
{
/// <summary>
/// Predicate type URI for RVA.
/// </summary>
public const string PredicateType = "https://stella.ops/predicates/risk-verdict@v1";
/// <summary>
/// Creates an in-toto statement from an RVA.
/// </summary>
public static RvaInTotoStatement CreateStatement(RiskVerdictAttestation attestation)
{
ArgumentNullException.ThrowIfNull(attestation);
return new RvaInTotoStatement
{
Type = "https://in-toto.io/Statement/v1",
Subject =
[
new RvaInTotoSubject
{
Name = attestation.Subject.Name ?? attestation.Subject.Digest,
Digest = new Dictionary<string, string>
{
["sha256"] = attestation.Subject.Digest.Replace("sha256:", "", StringComparison.Ordinal)
}
}
],
PredicateType = PredicateType,
Predicate = CreatePredicateContent(attestation)
};
}
private static RvaPredicateContent CreatePredicateContent(RiskVerdictAttestation attestation)
{
return new RvaPredicateContent
{
AttestationId = attestation.AttestationId,
SchemaVersion = attestation.SchemaVersion,
Verdict = attestation.Verdict.ToString(),
Policy = new PolicyPredicateRef
{
Id = attestation.Policy.PolicyId,
Version = attestation.Policy.Version,
Digest = attestation.Policy.Digest
},
KnowledgeSnapshotId = attestation.KnowledgeSnapshotId,
Evidence = attestation.Evidence.Select(e => new EvidencePredicateRef
{
Type = e.Type,
Digest = e.Digest,
Uri = e.Uri
}).ToList(),
ReasonCodes = attestation.ReasonCodes.Select(c => c.ToString()).ToList(),
Unknowns = attestation.Unknowns is not null ? new UnknownsPredicateRef
{
Total = attestation.Unknowns.Total,
BlockingCount = attestation.Unknowns.BlockingCount
} : null,
AppliedExceptions = attestation.AppliedExceptions.ToList(),
Explanation = attestation.Explanation,
CreatedAt = attestation.CreatedAt.ToString("o"),
ExpiresAt = attestation.ExpiresAt?.ToString("o")
};
}
}
/// <summary>
/// In-toto statement structure for RVA.
/// </summary>
public sealed record RvaInTotoStatement
{
[JsonPropertyName("_type")]
public required string Type { get; init; }
[JsonPropertyName("subject")]
public required RvaInTotoSubject[] Subject { get; init; }
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
[JsonPropertyName("predicate")]
public required object Predicate { get; init; }
}
/// <summary>
/// In-toto subject structure for RVA.
/// </summary>
public sealed record RvaInTotoSubject
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("digest")]
public required Dictionary<string, string> Digest { get; init; }
}
/// <summary>
/// RVA predicate content.
/// </summary>
public sealed record RvaPredicateContent
{
[JsonPropertyName("attestationId")]
public required string AttestationId { get; init; }
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0";
[JsonPropertyName("verdict")]
public required string Verdict { get; init; }
[JsonPropertyName("policy")]
public required PolicyPredicateRef Policy { get; init; }
[JsonPropertyName("knowledgeSnapshotId")]
public required string KnowledgeSnapshotId { get; init; }
[JsonPropertyName("evidence")]
public IReadOnlyList<EvidencePredicateRef> Evidence { get; init; } = [];
[JsonPropertyName("reasonCodes")]
public required IReadOnlyList<string> ReasonCodes { get; init; }
[JsonPropertyName("unknowns")]
public UnknownsPredicateRef? Unknowns { get; init; }
[JsonPropertyName("appliedExceptions")]
public required IReadOnlyList<string> AppliedExceptions { get; init; }
[JsonPropertyName("explanation")]
public string? Explanation { get; init; }
[JsonPropertyName("createdAt")]
public required string CreatedAt { get; init; }
[JsonPropertyName("expiresAt")]
public string? ExpiresAt { get; init; }
}
/// <summary>
/// Policy reference in predicate.
/// </summary>
public sealed record PolicyPredicateRef
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("digest")]
public required string Digest { get; init; }
}
/// <summary>
/// Evidence reference in predicate.
/// </summary>
public sealed record EvidencePredicateRef
{
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("digest")]
public required string Digest { get; init; }
[JsonPropertyName("uri")]
public string? Uri { get; init; }
}
/// <summary>
/// Unknowns reference in predicate.
/// </summary>
public sealed record UnknownsPredicateRef
{
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("blockingCount")]
public int BlockingCount { get; init; }
}

View File

@@ -0,0 +1,235 @@
using Microsoft.Extensions.Logging;
using StellaOps.Cryptography;
using StellaOps.Policy.Snapshots;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Service for creating and managing Risk Verdict Attestations.
/// </summary>
public sealed class RvaService : IRvaService
{
private readonly ICryptoHash _cryptoHash;
private readonly ISnapshotService _snapshotService;
private readonly IRvaStore _store;
private readonly ILogger<RvaService> _logger;
public RvaService(
ICryptoHash cryptoHash,
ISnapshotService snapshotService,
IRvaStore store,
ILogger<RvaService> logger)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Creates an RVA from a builder, validating the snapshot reference.
/// </summary>
public async Task<RiskVerdictAttestation> CreateAttestationAsync(
RvaBuilder builder,
CancellationToken ct = default)
{
var attestation = builder.Build();
// Validate snapshot exists
var snapshot = await _snapshotService.GetSnapshotAsync(attestation.KnowledgeSnapshotId, ct)
.ConfigureAwait(false);
if (snapshot is null)
{
throw new InvalidOperationException(
$"Knowledge snapshot {attestation.KnowledgeSnapshotId} not found");
}
// Verify snapshot integrity
var verification = await _snapshotService.VerifySnapshotAsync(snapshot, ct)
.ConfigureAwait(false);
if (!verification.IsValid)
{
throw new InvalidOperationException(
$"Knowledge snapshot verification failed: {verification.Error}");
}
// Store the attestation
await _store.SaveAsync(attestation, ct).ConfigureAwait(false);
_logger.LogInformation(
"Created RVA {AttestationId} with verdict {Verdict} for {Artifact} using snapshot {SnapshotId}",
attestation.AttestationId,
attestation.Verdict,
attestation.Subject.Digest,
attestation.KnowledgeSnapshotId);
return attestation;
}
/// <summary>
/// Validates that an RVA can be replayed with its referenced snapshot.
/// </summary>
public async Task<ReplayValidation> ValidateForReplayAsync(
RiskVerdictAttestation attestation,
CancellationToken ct = default)
{
// Check snapshot exists
var snapshot = await _snapshotService.GetSnapshotAsync(attestation.KnowledgeSnapshotId, ct)
.ConfigureAwait(false);
if (snapshot is null)
{
return ReplayValidation.Fail("Knowledge snapshot not found");
}
// Check snapshot integrity
var verification = await _snapshotService.VerifySnapshotAsync(snapshot, ct)
.ConfigureAwait(false);
if (!verification.IsValid)
{
return ReplayValidation.Fail($"Snapshot verification failed: {verification.Error}");
}
return ReplayValidation.Success(snapshot);
}
/// <summary>
/// Retrieves an attestation by ID.
/// </summary>
public async Task<RiskVerdictAttestation?> GetAttestationAsync(
string attestationId,
CancellationToken ct = default)
{
return await _store.GetAsync(attestationId, ct).ConfigureAwait(false);
}
/// <summary>
/// Lists attestations for a subject.
/// </summary>
public async Task<IReadOnlyList<RiskVerdictAttestation>> GetAttestationsForSubjectAsync(
string subjectDigest,
CancellationToken ct = default)
{
return await _store.GetBySubjectAsync(subjectDigest, ct).ConfigureAwait(false);
}
}
/// <summary>
/// Result of replay validation.
/// </summary>
public sealed record ReplayValidation(
bool CanReplay,
string? Error,
KnowledgeSnapshotManifest? Snapshot)
{
public static ReplayValidation Success(KnowledgeSnapshotManifest snapshot) =>
new(true, null, snapshot);
public static ReplayValidation Fail(string error) =>
new(false, error, null);
}
/// <summary>
/// Interface for RVA service.
/// </summary>
public interface IRvaService
{
/// <summary>
/// Creates an RVA from a builder.
/// </summary>
Task<RiskVerdictAttestation> CreateAttestationAsync(RvaBuilder builder, CancellationToken ct = default);
/// <summary>
/// Validates that an RVA can be replayed.
/// </summary>
Task<ReplayValidation> ValidateForReplayAsync(RiskVerdictAttestation attestation, CancellationToken ct = default);
/// <summary>
/// Retrieves an attestation by ID.
/// </summary>
Task<RiskVerdictAttestation?> GetAttestationAsync(string attestationId, CancellationToken ct = default);
/// <summary>
/// Lists attestations for a subject.
/// </summary>
Task<IReadOnlyList<RiskVerdictAttestation>> GetAttestationsForSubjectAsync(string subjectDigest, CancellationToken ct = default);
}
/// <summary>
/// Interface for RVA persistence.
/// </summary>
public interface IRvaStore
{
/// <summary>
/// Saves an attestation.
/// </summary>
Task SaveAsync(RiskVerdictAttestation attestation, CancellationToken ct = default);
/// <summary>
/// Retrieves an attestation by ID.
/// </summary>
Task<RiskVerdictAttestation?> GetAsync(string attestationId, CancellationToken ct = default);
/// <summary>
/// Gets attestations for a subject digest.
/// </summary>
Task<IReadOnlyList<RiskVerdictAttestation>> GetBySubjectAsync(string subjectDigest, CancellationToken ct = default);
/// <summary>
/// Deletes an attestation.
/// </summary>
Task<bool> DeleteAsync(string attestationId, CancellationToken ct = default);
}
/// <summary>
/// In-memory implementation of <see cref="IRvaStore"/> for testing.
/// </summary>
public sealed class InMemoryRvaStore : IRvaStore
{
private readonly Dictionary<string, RiskVerdictAttestation> _attestations = new();
private readonly object _lock = new();
public Task SaveAsync(RiskVerdictAttestation attestation, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
lock (_lock)
{
_attestations[attestation.AttestationId] = attestation;
}
return Task.CompletedTask;
}
public Task<RiskVerdictAttestation?> GetAsync(string attestationId, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
lock (_lock)
{
return Task.FromResult(_attestations.TryGetValue(attestationId, out var att) ? att : null);
}
}
public Task<IReadOnlyList<RiskVerdictAttestation>> GetBySubjectAsync(string subjectDigest, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
lock (_lock)
{
var result = _attestations.Values
.Where(a => a.Subject.Digest == subjectDigest)
.OrderByDescending(a => a.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<RiskVerdictAttestation>>(result);
}
}
public Task<bool> DeleteAsync(string attestationId, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
lock (_lock)
{
return Task.FromResult(_attestations.Remove(attestationId));
}
}
}

View File

@@ -0,0 +1,348 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Envelope;
using StellaOps.Cryptography;
using StellaOps.Policy.Snapshots;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Verifies Risk Verdict Attestation signatures and integrity.
/// </summary>
public sealed class RvaVerifier : IRvaVerifier
{
private readonly ICryptoSigner? _signer;
private readonly ISnapshotService _snapshotService;
private readonly ILogger<RvaVerifier> _logger;
public RvaVerifier(
ISnapshotService snapshotService,
ILogger<RvaVerifier> logger,
ICryptoSigner? signer = null)
{
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_signer = signer;
}
/// <summary>
/// Verifies a DSSE-wrapped RVA.
/// </summary>
public async Task<RvaVerificationResult> VerifyAsync(
DsseEnvelope envelope,
RvaVerificationOptions options,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(envelope);
var issues = new List<string>();
string? signerIdentity = null;
// Step 1: Verify DSSE signature
if (_signer is not null)
{
var sigResult = await VerifySignatureAsync(envelope, options, ct).ConfigureAwait(false);
signerIdentity = sigResult.SignerIdentity;
if (!sigResult.IsValid)
{
issues.Add($"Signature verification failed: {sigResult.Error}");
if (!options.ContinueOnSignatureFailure)
{
return RvaVerificationResult.Fail(issues);
}
}
}
// Step 2: Parse payload
var attestation = ParsePayload(envelope);
if (attestation is null)
{
issues.Add("Failed to parse RVA payload");
return RvaVerificationResult.Fail(issues);
}
// Step 3: Verify content-addressed ID
var idValid = VerifyAttestationId(attestation);
if (!idValid)
{
issues.Add("Attestation ID does not match content");
return RvaVerificationResult.Fail(issues);
}
// Step 4: Verify expiration
if (options.CheckExpiration && attestation.ExpiresAt.HasValue)
{
if (attestation.ExpiresAt.Value < DateTimeOffset.UtcNow)
{
issues.Add($"Attestation expired at {attestation.ExpiresAt.Value:o}");
if (!options.AllowExpired)
{
return RvaVerificationResult.Fail(issues);
}
}
}
// Step 5: Verify knowledge snapshot exists (if requested)
if (options.VerifySnapshotExists)
{
var snapshot = await _snapshotService.GetSnapshotAsync(attestation.KnowledgeSnapshotId, ct)
.ConfigureAwait(false);
if (snapshot is null)
{
issues.Add($"Knowledge snapshot {attestation.KnowledgeSnapshotId} not found");
}
}
var isValid = issues.Count == 0 ||
issues.All(i => i.Contains("expired", StringComparison.OrdinalIgnoreCase) && options.AllowExpired);
return new RvaVerificationResult
{
IsValid = isValid,
Attestation = attestation,
SignerIdentity = signerIdentity,
Issues = issues,
VerifiedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Verifies a raw RVA (unsigned) for integrity.
/// </summary>
public Task<RvaVerificationResult> VerifyRawAsync(
RiskVerdictAttestation attestation,
RvaVerificationOptions options,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(attestation);
var issues = new List<string>();
// Verify content-addressed ID
var idValid = VerifyAttestationId(attestation);
if (!idValid)
{
issues.Add("Attestation ID does not match content");
return Task.FromResult(RvaVerificationResult.Fail(issues));
}
// Verify expiration
if (options.CheckExpiration && attestation.ExpiresAt.HasValue)
{
if (attestation.ExpiresAt.Value < DateTimeOffset.UtcNow)
{
issues.Add($"Attestation expired at {attestation.ExpiresAt.Value:o}");
if (!options.AllowExpired)
{
return Task.FromResult(RvaVerificationResult.Fail(issues));
}
}
}
var isValid = issues.Count == 0 ||
issues.All(i => i.Contains("expired", StringComparison.OrdinalIgnoreCase) && options.AllowExpired);
return Task.FromResult(new RvaVerificationResult
{
IsValid = isValid,
Attestation = attestation,
SignerIdentity = null,
Issues = issues,
VerifiedAt = DateTimeOffset.UtcNow
});
}
/// <summary>
/// Quick verification of just the signature.
/// </summary>
public async Task<SignatureVerificationResult> VerifySignatureAsync(
DsseEnvelope envelope,
RvaVerificationOptions options,
CancellationToken ct = default)
{
if (_signer is null)
{
return new SignatureVerificationResult
{
IsValid = false,
Error = "No signer configured for verification"
};
}
try
{
var payload = envelope.Payload;
var signatureBase64 = envelope.Signatures[0].Signature;
var signature = Convert.FromBase64String(signatureBase64);
var isValid = await _signer.VerifyAsync(payload, signature, ct).ConfigureAwait(false);
return new SignatureVerificationResult
{
IsValid = isValid,
SignerIdentity = envelope.Signatures[0].KeyId
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Signature verification failed");
return new SignatureVerificationResult
{
IsValid = false,
Error = ex.Message
};
}
}
private RiskVerdictAttestation? ParsePayload(DsseEnvelope envelope)
{
try
{
var payloadBytes = envelope.Payload.ToArray();
var statement = JsonSerializer.Deserialize<RvaInTotoStatement>(payloadBytes);
if (statement?.PredicateType != RvaPredicate.PredicateType)
return null;
var predicateJson = JsonSerializer.Serialize(statement.Predicate);
var predicate = JsonSerializer.Deserialize<RvaPredicateContent>(predicateJson);
if (predicate is null)
return null;
return ConvertToRva(statement, predicate);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse RVA payload");
return null;
}
}
private static RiskVerdictAttestation ConvertToRva(RvaInTotoStatement statement, RvaPredicateContent predicate)
{
var subject = statement.Subject[0];
var digest = subject.Digest.TryGetValue("sha256", out var sha) ? $"sha256:{sha}" : subject.Name;
return new RiskVerdictAttestation
{
AttestationId = predicate.AttestationId,
SchemaVersion = predicate.SchemaVersion,
CreatedAt = DateTimeOffset.Parse(predicate.CreatedAt),
Verdict = Enum.Parse<RiskVerdictStatus>(predicate.Verdict),
Subject = new ArtifactSubject
{
Digest = digest,
Type = "container-image",
Name = subject.Name
},
Policy = new RvaPolicyRef
{
PolicyId = predicate.Policy.Id,
Version = predicate.Policy.Version,
Digest = predicate.Policy.Digest
},
KnowledgeSnapshotId = predicate.KnowledgeSnapshotId,
Evidence = predicate.Evidence.Select(e => new RvaEvidenceRef
{
Type = e.Type,
Digest = e.Digest,
Uri = e.Uri
}).ToList(),
ReasonCodes = predicate.ReasonCodes
.Select(c => Enum.TryParse<VerdictReasonCode>(c, out var code) ? code : VerdictReasonCode.PassNoCves)
.ToList(),
Unknowns = predicate.Unknowns is not null ? new UnknownsSummary
{
Total = predicate.Unknowns.Total,
BlockingCount = predicate.Unknowns.BlockingCount
} : null,
AppliedExceptions = predicate.AppliedExceptions,
Explanation = predicate.Explanation,
ExpiresAt = predicate.ExpiresAt is not null ? DateTimeOffset.Parse(predicate.ExpiresAt) : null
};
}
private static bool VerifyAttestationId(RiskVerdictAttestation attestation)
{
var json = JsonSerializer.Serialize(attestation with { AttestationId = "" },
RvaSerializerOptions.Canonical);
var expectedId = $"rva:sha256:{ComputeSha256(json)}";
return attestation.AttestationId == expectedId;
}
private static string ComputeSha256(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
/// <summary>
/// Result of RVA verification.
/// </summary>
public sealed record RvaVerificationResult
{
public required bool IsValid { get; init; }
public RiskVerdictAttestation? Attestation { get; init; }
public string? SignerIdentity { get; init; }
public IReadOnlyList<string> Issues { get; init; } = [];
public DateTimeOffset VerifiedAt { get; init; }
public static RvaVerificationResult Fail(IReadOnlyList<string> issues) =>
new() { IsValid = false, Issues = issues, VerifiedAt = DateTimeOffset.UtcNow };
}
/// <summary>
/// Result of signature verification.
/// </summary>
public sealed record SignatureVerificationResult
{
public required bool IsValid { get; init; }
public string? SignerIdentity { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Options for RVA verification.
/// </summary>
public sealed record RvaVerificationOptions
{
public bool CheckExpiration { get; init; } = true;
public bool AllowExpired { get; init; } = false;
public bool VerifySnapshotExists { get; init; } = false;
public bool VerifySignerIdentity { get; init; } = true;
public bool ContinueOnSignatureFailure { get; init; } = false;
public static RvaVerificationOptions Default { get; } = new();
public static RvaVerificationOptions Strict { get; } = new()
{
VerifySnapshotExists = true,
AllowExpired = false
};
}
/// <summary>
/// Interface for RVA verification.
/// </summary>
public interface IRvaVerifier
{
/// <summary>
/// Verifies a DSSE-wrapped RVA.
/// </summary>
Task<RvaVerificationResult> VerifyAsync(DsseEnvelope envelope, RvaVerificationOptions options, CancellationToken ct = default);
/// <summary>
/// Verifies a raw RVA for integrity.
/// </summary>
Task<RvaVerificationResult> VerifyRawAsync(RiskVerdictAttestation attestation, RvaVerificationOptions options, CancellationToken ct = default);
/// <summary>
/// Verifies just the signature.
/// </summary>
Task<SignatureVerificationResult> VerifySignatureAsync(DsseEnvelope envelope, RvaVerificationOptions options, CancellationToken ct = default);
}

View File

@@ -0,0 +1,188 @@
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Structured reason codes explaining verdict outcomes.
/// Format: CATEGORY.SUBCATEGORY.DETAIL
/// </summary>
public enum VerdictReasonCode
{
// PASS reasons
/// <summary>
/// No CVEs found in artifact.
/// </summary>
PassNoCves,
/// <summary>
/// All CVEs are not reachable.
/// </summary>
PassNotReachable,
/// <summary>
/// All CVEs are covered by VEX not_affected statements.
/// </summary>
PassVexNotAffected,
/// <summary>
/// All CVEs are below severity threshold.
/// </summary>
PassBelowThreshold,
// FAIL reasons - CVE
/// <summary>
/// Reachable CVE exceeds severity threshold.
/// </summary>
FailCveReachable,
/// <summary>
/// CVE in CISA KEV (Known Exploited Vulnerabilities).
/// </summary>
FailCveKev,
/// <summary>
/// CVE with high EPSS score.
/// </summary>
FailCveEpss,
/// <summary>
/// CVE severity exceeds maximum allowed.
/// </summary>
FailCveSeverity,
// FAIL reasons - Policy
/// <summary>
/// License violation detected.
/// </summary>
FailPolicyLicense,
/// <summary>
/// Blocked package detected.
/// </summary>
FailPolicyBlockedPackage,
/// <summary>
/// Unknown budget exceeded.
/// </summary>
FailPolicyUnknownBudget,
/// <summary>
/// SBOM completeness below threshold.
/// </summary>
FailPolicySbomCompleteness,
// FAIL reasons - Provenance
/// <summary>
/// Missing provenance attestation.
/// </summary>
FailProvenanceMissing,
/// <summary>
/// Provenance signature invalid.
/// </summary>
FailProvenanceInvalid,
// EXCEPTION reasons
/// <summary>
/// CVE covered by approved exception.
/// </summary>
ExceptionCve,
/// <summary>
/// License covered by approved exception.
/// </summary>
ExceptionLicense,
/// <summary>
/// Unknowns covered by approved exception.
/// </summary>
ExceptionUnknown,
// INDETERMINATE reasons
/// <summary>
/// Insufficient data to evaluate.
/// </summary>
IndeterminateInsufficientData,
/// <summary>
/// Analyzer does not support this artifact type.
/// </summary>
IndeterminateUnsupported,
/// <summary>
/// Conflicting VEX statements.
/// </summary>
IndeterminateVexConflict,
/// <summary>
/// Required knowledge source unavailable.
/// </summary>
IndeterminateFeedUnavailable
}
/// <summary>
/// Extension methods for reason code handling.
/// </summary>
public static class VerdictReasonCodeExtensions
{
/// <summary>
/// Gets the category of a reason code (Pass, Fail, Exception, Indeterminate).
/// </summary>
public static string GetCategory(this VerdictReasonCode code)
{
return code.ToString() switch
{
var s when s.StartsWith("Pass", StringComparison.Ordinal) => "Pass",
var s when s.StartsWith("Fail", StringComparison.Ordinal) => "Fail",
var s when s.StartsWith("Exception", StringComparison.Ordinal) => "Exception",
var s when s.StartsWith("Indeterminate", StringComparison.Ordinal) => "Indeterminate",
_ => "Unknown"
};
}
/// <summary>
/// Gets a human-readable description of the reason code.
/// </summary>
public static string GetDescription(this VerdictReasonCode code)
{
return code switch
{
VerdictReasonCode.PassNoCves => "No CVEs found in artifact",
VerdictReasonCode.PassNotReachable => "All CVEs are not reachable",
VerdictReasonCode.PassVexNotAffected => "All CVEs covered by VEX not_affected statements",
VerdictReasonCode.PassBelowThreshold => "All CVEs below severity threshold",
VerdictReasonCode.FailCveReachable => "Reachable CVE exceeds severity threshold",
VerdictReasonCode.FailCveKev => "CVE in CISA Known Exploited Vulnerabilities list",
VerdictReasonCode.FailCveEpss => "CVE with high EPSS score",
VerdictReasonCode.FailCveSeverity => "CVE severity exceeds maximum allowed",
VerdictReasonCode.FailPolicyLicense => "License violation detected",
VerdictReasonCode.FailPolicyBlockedPackage => "Blocked package detected",
VerdictReasonCode.FailPolicyUnknownBudget => "Unknown budget exceeded",
VerdictReasonCode.FailPolicySbomCompleteness => "SBOM completeness below threshold",
VerdictReasonCode.FailProvenanceMissing => "Missing provenance attestation",
VerdictReasonCode.FailProvenanceInvalid => "Provenance signature invalid",
VerdictReasonCode.ExceptionCve => "CVE covered by approved exception",
VerdictReasonCode.ExceptionLicense => "License covered by approved exception",
VerdictReasonCode.ExceptionUnknown => "Unknowns covered by approved exception",
VerdictReasonCode.IndeterminateInsufficientData => "Insufficient data to evaluate",
VerdictReasonCode.IndeterminateUnsupported => "Analyzer does not support this artifact type",
VerdictReasonCode.IndeterminateVexConflict => "Conflicting VEX statements",
VerdictReasonCode.IndeterminateFeedUnavailable => "Required knowledge source unavailable",
_ => code.ToString()
};
}
/// <summary>
/// Checks if a reason code indicates a passing state.
/// </summary>
public static bool IsPass(this VerdictReasonCode code)
{
return code.GetCategory() == "Pass";
}
/// <summary>
/// Checks if a reason code indicates a failing state.
/// </summary>
public static bool IsFail(this VerdictReasonCode code)
{
return code.GetCategory() == "Fail";
}
}