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:
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user