feat: add Attestation Chain and Triage Evidence API clients and models

- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains.
- Created models for Attestation Chain, including DSSE envelope structures and verification results.
- Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component.
- Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence.
- Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 deletions

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Result of publishing a reachability witness.
/// </summary>
/// <param name="StatementHash">Hash of the in-toto statement.</param>
/// <param name="GraphHash">Hash of the rich graph.</param>
/// <param name="CasUri">CAS URI where graph is stored (if applicable).</param>
/// <param name="RekorLogIndex">Rekor transparency log index (if published).</param>
/// <param name="RekorLogId">Rekor log ID (if published).</param>
/// <param name="DsseEnvelopeBytes">Serialized DSSE envelope.</param>
public sealed record ReachabilityWitnessPublishResult(
string StatementHash,
string GraphHash,
string? CasUri,
long? RekorLogIndex,
string? RekorLogId,
byte[] DsseEnvelopeBytes);
/// <summary>
/// Interface for publishing reachability witness attestations.
/// </summary>
public interface IReachabilityWitnessPublisher
{
/// <summary>
/// Publishes a reachability witness attestation for the given graph.
/// </summary>
/// <param name="graph">The rich graph to attest.</param>
/// <param name="graphBytes">Canonical JSON bytes of the graph.</param>
/// <param name="graphHash">Hash of the graph bytes.</param>
/// <param name="subjectDigest">Subject artifact digest.</param>
/// <param name="policyHash">Optional policy hash.</param>
/// <param name="sourceCommit">Optional source commit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Publication result with CAS URI and optional Rekor proof.</returns>
Task<ReachabilityWitnessPublishResult> PublishAsync(
RichGraph graph,
byte[] graphBytes,
string graphHash,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,207 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Builds DSSE envelopes for reachability witness attestations.
/// Follows in-toto attestation framework with stellaops.reachabilityWitness predicate.
/// </summary>
public sealed class ReachabilityWitnessDsseBuilder
{
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Creates a new DSSE builder.
/// </summary>
/// <param name="cryptoHash">Crypto hash service for content addressing.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
public ReachabilityWitnessDsseBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Builds an in-toto statement from a RichGraph.
/// </summary>
/// <param name="graph">The rich graph to attest.</param>
/// <param name="graphHash">The computed hash of the canonical graph JSON.</param>
/// <param name="subjectDigest">The subject artifact digest (e.g., image digest).</param>
/// <param name="graphCasUri">Optional CAS URI where graph is stored.</param>
/// <param name="policyHash">Optional policy hash that was applied.</param>
/// <param name="sourceCommit">Optional source commit.</param>
/// <returns>An in-toto statement ready for DSSE signing.</returns>
public InTotoStatement BuildStatement(
RichGraph graph,
string graphHash,
string subjectDigest,
string? graphCasUri = null,
string? policyHash = null,
string? sourceCommit = null)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
var generatedAt = _timeProvider.GetUtcNow();
var predicate = new ReachabilityWitnessStatement
{
GraphHash = graphHash,
GraphCasUri = graphCasUri,
GeneratedAt = generatedAt,
Language = graph.Nodes.FirstOrDefault()?.Lang ?? "unknown",
NodeCount = graph.Nodes.Count,
EdgeCount = graph.Edges.Count,
EntrypointCount = graph.Roots?.Count ?? 0,
SinkCount = CountSinks(graph),
ReachableSinkCount = CountReachableSinks(graph),
PolicyHash = policyHash,
AnalyzerVersion = graph.Analyzer.Version ?? "unknown",
SourceCommit = sourceCommit,
SubjectDigest = subjectDigest
};
return new InTotoStatement
{
Type = "https://in-toto.io/Statement/v1",
Subject = new[]
{
new InTotoSubject
{
Name = ExtractSubjectName(subjectDigest),
Digest = new Dictionary<string, string>
{
[ExtractDigestAlgorithm(subjectDigest)] = ExtractDigestValue(subjectDigest)
}
}
},
PredicateType = "https://stella.ops/reachabilityWitness/v1",
Predicate = predicate
};
}
/// <summary>
/// Serializes an in-toto statement to canonical JSON.
/// </summary>
public byte[] SerializeStatement(InTotoStatement statement)
{
ArgumentNullException.ThrowIfNull(statement);
return JsonSerializer.SerializeToUtf8Bytes(statement, CanonicalJsonOptions);
}
/// <summary>
/// Computes the hash of a serialized statement.
/// </summary>
public string ComputeStatementHash(byte[] statementBytes)
{
ArgumentNullException.ThrowIfNull(statementBytes);
return _cryptoHash.ComputePrefixedHashForPurpose(statementBytes, HashPurpose.Graph);
}
private static int CountSinks(RichGraph graph)
{
// Count nodes with sink-related kinds (sql, crypto, deserialize, etc.)
return graph.Nodes.Count(n => IsSinkKind(n.Kind));
}
private static int CountReachableSinks(RichGraph graph)
{
// A sink is reachable if it has incoming edges
var nodesWithIncoming = new HashSet<string>(StringComparer.Ordinal);
foreach (var edge in graph.Edges)
{
if (!string.IsNullOrEmpty(edge.To))
{
nodesWithIncoming.Add(edge.To);
}
}
return graph.Nodes.Count(n =>
IsSinkKind(n.Kind) &&
nodesWithIncoming.Contains(n.Id));
}
private static bool IsSinkKind(string? kind)
{
// Recognize common sink kinds from the taxonomy
return kind?.ToLowerInvariant() switch
{
"sink" => true,
"sql" => true,
"crypto" => true,
"deserialize" => true,
"file" => true,
"network" => true,
"command" => true,
"reflection" => true,
_ => false
};
}
private static string ExtractSubjectName(string subjectDigest)
{
// For image digests like "sha256:abc123", return the full string
// For other formats, try to extract a meaningful name
return subjectDigest;
}
private static string ExtractDigestAlgorithm(string subjectDigest)
{
var colonIndex = subjectDigest.IndexOf(':');
return colonIndex > 0 ? subjectDigest[..colonIndex] : "sha256";
}
private static string ExtractDigestValue(string subjectDigest)
{
var colonIndex = subjectDigest.IndexOf(':');
return colonIndex > 0 ? subjectDigest[(colonIndex + 1)..] : subjectDigest;
}
}
/// <summary>
/// In-toto Statement structure per https://github.com/in-toto/attestation.
/// </summary>
public sealed record InTotoStatement
{
/// <summary>Statement type (always "https://in-toto.io/Statement/v1")</summary>
[JsonPropertyName("_type")]
public required string Type { get; init; }
/// <summary>Array of subjects this attestation refers to</summary>
[JsonPropertyName("subject")]
public required InTotoSubject[] Subject { get; init; }
/// <summary>URI identifying the predicate type</summary>
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
/// <summary>The predicate object (type varies by predicateType)</summary>
[JsonPropertyName("predicate")]
public required object Predicate { get; init; }
}
/// <summary>
/// In-toto Subject structure.
/// </summary>
public sealed record InTotoSubject
{
/// <summary>Subject name (e.g., artifact path or identifier)</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Map of digest algorithm to digest value</summary>
[JsonPropertyName("digest")]
public required Dictionary<string, string> Digest { get; init; }
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Configuration for reachability witness attestation.
/// </summary>
public sealed class ReachabilityWitnessOptions
{
public const string SectionName = "Scanner:ReachabilityWitness";
/// <summary>Whether to generate DSSE attestations</summary>
public bool Enabled { get; set; } = true;
/// <summary>Attestation tier (standard, regulated, air-gapped, dev)</summary>
public AttestationTier Tier { get; set; } = AttestationTier.Standard;
/// <summary>Whether to publish to Rekor transparency log</summary>
public bool PublishToRekor { get; set; } = true;
/// <summary>Whether to store graph in CAS</summary>
public bool StoreInCas { get; set; } = true;
/// <summary>Maximum number of edge bundles to attest (for tier=standard)</summary>
public int MaxEdgeBundles { get; set; } = 5;
/// <summary>Key ID for signing (uses default if not specified)</summary>
public string? SigningKeyId { get; set; }
}
/// <summary>
/// Attestation tiers per hybrid-attestation.md.
/// </summary>
public enum AttestationTier
{
/// <summary>Standard: Graph DSSE + Rekor, optional edge bundles</summary>
Standard,
/// <summary>Regulated: Full attestation with strict signing</summary>
Regulated,
/// <summary>Air-gapped: Local-only, no Rekor</summary>
AirGapped,
/// <summary>Development: Minimal attestation for testing</summary>
Dev
}

View File

@@ -0,0 +1,147 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Publishes reachability witness attestations to CAS and Rekor.
/// </summary>
public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
{
private readonly ReachabilityWitnessOptions _options;
private readonly ReachabilityWitnessDsseBuilder _dsseBuilder;
private readonly ICryptoHash _cryptoHash;
private readonly ILogger<ReachabilityWitnessPublisher> _logger;
/// <summary>
/// Creates a new reachability witness publisher.
/// </summary>
public ReachabilityWitnessPublisher(
IOptions<ReachabilityWitnessOptions> options,
ICryptoHash cryptoHash,
ILogger<ReachabilityWitnessPublisher> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(cryptoHash);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value;
_cryptoHash = cryptoHash;
_logger = logger;
_dsseBuilder = new ReachabilityWitnessDsseBuilder(cryptoHash, timeProvider);
}
/// <inheritdoc />
public async Task<ReachabilityWitnessPublishResult> PublishAsync(
RichGraph graph,
byte[] graphBytes,
string graphHash,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(graphBytes);
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
if (!_options.Enabled)
{
_logger.LogDebug("Reachability witness attestation is disabled");
return new ReachabilityWitnessPublishResult(
StatementHash: string.Empty,
GraphHash: graphHash,
CasUri: null,
RekorLogIndex: null,
RekorLogId: null,
DsseEnvelopeBytes: Array.Empty<byte>());
}
string? casUri = null;
// Step 1: Store graph in CAS (if enabled)
if (_options.StoreInCas)
{
casUri = await StoreInCasAsync(graphBytes, graphHash, cancellationToken).ConfigureAwait(false);
}
// Step 2: Build in-toto statement
var statement = _dsseBuilder.BuildStatement(
graph,
graphHash,
subjectDigest,
casUri,
policyHash,
sourceCommit);
var statementBytes = _dsseBuilder.SerializeStatement(statement);
var statementHash = _dsseBuilder.ComputeStatementHash(statementBytes);
_logger.LogInformation(
"Built reachability witness statement: hash={StatementHash}, nodes={NodeCount}, edges={EdgeCount}",
statementHash,
graph.Nodes.Count,
graph.Edges.Count);
// Step 3: Create DSSE envelope (placeholder - actual signing via Attestor service)
var dsseEnvelope = CreateDsseEnvelope(statementBytes);
// Step 4: Submit to Rekor (if enabled and not air-gapped)
long? rekorLogIndex = null;
string? rekorLogId = null;
if (_options.PublishToRekor && _options.Tier != AttestationTier.AirGapped)
{
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(dsseEnvelope, cancellationToken).ConfigureAwait(false);
}
else if (_options.Tier == AttestationTier.AirGapped)
{
_logger.LogDebug("Skipping Rekor submission (air-gapped tier)");
}
return new ReachabilityWitnessPublishResult(
StatementHash: statementHash,
GraphHash: graphHash,
CasUri: casUri,
RekorLogIndex: rekorLogIndex,
RekorLogId: rekorLogId,
DsseEnvelopeBytes: dsseEnvelope);
}
private Task<string?> StoreInCasAsync(byte[] graphBytes, string graphHash, CancellationToken cancellationToken)
{
// TODO: Integrate with actual CAS storage (BID-007)
// For now, return a placeholder CAS URI based on hash
var casUri = $"cas://local/{graphHash}";
_logger.LogDebug("Stored graph in CAS: {CasUri}", casUri);
return Task.FromResult<string?>(casUri);
}
private byte[] CreateDsseEnvelope(byte[] statementBytes)
{
// TODO: Integrate with Attestor DSSE signing service (RWD-008)
// For now, return unsigned envelope structure
// In production, this would call the Attestor service to sign the statement
// Minimal DSSE envelope structure (unsigned)
var envelope = new
{
payloadType = "application/vnd.in-toto+json",
payload = Convert.ToBase64String(statementBytes),
signatures = Array.Empty<object>() // Will be populated by Attestor
};
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(envelope);
}
private Task<(long? logIndex, string? logId)> SubmitToRekorAsync(byte[] dsseEnvelope, CancellationToken cancellationToken)
{
// TODO: Integrate with Rekor backend (RWD-008)
// For now, return placeholder values
_logger.LogDebug("Rekor submission placeholder - actual integration pending");
return Task.FromResult<(long?, string?)>((null, null));
}
}

View File

@@ -0,0 +1,66 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Reachability witness statement for DSSE predicate.
/// Conforms to stella.ops/reachabilityWitness@v1 schema.
/// </summary>
public sealed record ReachabilityWitnessStatement
{
/// <summary>Schema identifier</summary>
[JsonPropertyName("schema")]
public string Schema { get; init; } = "stella.ops/reachabilityWitness@v1";
/// <summary>BLAKE3 hash of the canonical RichGraph JSON</summary>
[JsonPropertyName("graphHash")]
public required string GraphHash { get; init; }
/// <summary>CAS URI where graph is stored</summary>
[JsonPropertyName("graphCasUri")]
public string? GraphCasUri { get; init; }
/// <summary>When the analysis was performed (ISO-8601)</summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>Primary language of the analyzed code</summary>
[JsonPropertyName("language")]
public required string Language { get; init; }
/// <summary>Number of nodes in the graph</summary>
[JsonPropertyName("nodeCount")]
public required int NodeCount { get; init; }
/// <summary>Number of edges in the graph</summary>
[JsonPropertyName("edgeCount")]
public required int EdgeCount { get; init; }
/// <summary>Number of entrypoints identified</summary>
[JsonPropertyName("entrypointCount")]
public required int EntrypointCount { get; init; }
/// <summary>Total number of sinks in taxonomy</summary>
[JsonPropertyName("sinkCount")]
public required int SinkCount { get; init; }
/// <summary>Number of reachable sinks</summary>
[JsonPropertyName("reachableSinkCount")]
public required int ReachableSinkCount { get; init; }
/// <summary>Policy hash that was applied (if any)</summary>
[JsonPropertyName("policyHash")]
public string? PolicyHash { get; init; }
/// <summary>Analyzer version used</summary>
[JsonPropertyName("analyzerVersion")]
public required string AnalyzerVersion { get; init; }
/// <summary>Git commit of the analyzed code</summary>
[JsonPropertyName("sourceCommit")]
public string? SourceCommit { get; init; }
/// <summary>Subject artifact (image digest or file hash)</summary>
[JsonPropertyName("subjectDigest")]
public required string SubjectDigest { get; init; }
}