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 @@
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Result of emitting a native component.
|
||||
/// </summary>
|
||||
/// <param name="Purl">Package URL for the component.</param>
|
||||
/// <param name="Name">Component name (usually the filename).</param>
|
||||
/// <param name="Version">Component version if known.</param>
|
||||
/// <param name="Metadata">Original binary metadata.</param>
|
||||
/// <param name="IndexMatch">Whether this was matched from the Build-ID index.</param>
|
||||
/// <param name="LookupResult">The index lookup result if matched.</param>
|
||||
public sealed record NativeComponentEmitResult(
|
||||
string Purl,
|
||||
string Name,
|
||||
string? Version,
|
||||
NativeBinaryMetadata Metadata,
|
||||
bool IndexMatch,
|
||||
BuildIdLookupResult? LookupResult);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for emitting native binary components for SBOM generation.
|
||||
/// </summary>
|
||||
public interface INativeComponentEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits a native component from binary metadata.
|
||||
/// </summary>
|
||||
/// <param name="metadata">Binary metadata.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Component emission result.</returns>
|
||||
Task<NativeComponentEmitResult> EmitAsync(NativeBinaryMetadata metadata, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits multiple native components.
|
||||
/// </summary>
|
||||
/// <param name="metadataList">List of binary metadata.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Component emission results.</returns>
|
||||
Task<IReadOnlyList<NativeComponentEmitResult>> EmitBatchAsync(
|
||||
IEnumerable<NativeBinaryMetadata> metadataList,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace StellaOps.Scanner.Emit.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a native binary component.
|
||||
/// </summary>
|
||||
public sealed record NativeBinaryMetadata
|
||||
{
|
||||
/// <summary>Binary format (elf, pe, macho)</summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>Build-ID with prefix (gnu-build-id:..., pe-cv:..., macho-uuid:...)</summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>CPU architecture (x86_64, aarch64, arm, i686, etc.)</summary>
|
||||
public string? Architecture { get; init; }
|
||||
|
||||
/// <summary>Whether this is a 64-bit binary</summary>
|
||||
public bool Is64Bit { get; init; }
|
||||
|
||||
/// <summary>Operating system or platform</summary>
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>File path within the container layer</summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>SHA-256 digest of the file</summary>
|
||||
public string? FileDigest { get; init; }
|
||||
|
||||
/// <summary>File size in bytes</summary>
|
||||
public long FileSize { get; init; }
|
||||
|
||||
/// <summary>Container layer digest where this binary was introduced</summary>
|
||||
public string? LayerDigest { get; init; }
|
||||
|
||||
/// <summary>Layer index (0-based)</summary>
|
||||
public int LayerIndex { get; init; }
|
||||
|
||||
/// <summary>Product version from PE version resource</summary>
|
||||
public string? ProductVersion { get; init; }
|
||||
|
||||
/// <summary>File version from PE version resource</summary>
|
||||
public string? FileVersion { get; init; }
|
||||
|
||||
/// <summary>Company name from PE version resource</summary>
|
||||
public string? CompanyName { get; init; }
|
||||
|
||||
/// <summary>Hardening flags (PIE, RELRO, NX, etc.)</summary>
|
||||
public IReadOnlyDictionary<string, string>? HardeningFlags { get; init; }
|
||||
|
||||
/// <summary>Whether the binary is signed</summary>
|
||||
public bool IsSigned { get; init; }
|
||||
|
||||
/// <summary>Signature details (Authenticode, codesign, etc.)</summary>
|
||||
public string? SignatureDetails { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Emits native binary components for SBOM generation.
|
||||
/// Uses the Build-ID index to resolve PURLs when possible.
|
||||
/// </summary>
|
||||
public sealed class NativeComponentEmitter : INativeComponentEmitter
|
||||
{
|
||||
private readonly IBuildIdIndex _buildIdIndex;
|
||||
private readonly NativePurlBuilder _purlBuilder;
|
||||
private readonly ILogger<NativeComponentEmitter> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new native component emitter.
|
||||
/// </summary>
|
||||
public NativeComponentEmitter(
|
||||
IBuildIdIndex buildIdIndex,
|
||||
ILogger<NativeComponentEmitter> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buildIdIndex);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_buildIdIndex = buildIdIndex;
|
||||
_purlBuilder = new NativePurlBuilder();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NativeComponentEmitResult> EmitAsync(
|
||||
NativeBinaryMetadata metadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
// Try to resolve via Build-ID index
|
||||
BuildIdLookupResult? lookupResult = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.BuildId))
|
||||
{
|
||||
lookupResult = await _buildIdIndex.LookupAsync(metadata.BuildId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string purl;
|
||||
string? version = null;
|
||||
bool indexMatch = false;
|
||||
|
||||
if (lookupResult is not null)
|
||||
{
|
||||
// Index match - use the resolved PURL
|
||||
purl = _purlBuilder.FromIndexResult(lookupResult);
|
||||
version = lookupResult.Version;
|
||||
indexMatch = true;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Resolved binary {FilePath} via Build-ID index: {Purl}",
|
||||
metadata.FilePath,
|
||||
purl);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No match - generate generic PURL
|
||||
purl = _purlBuilder.FromUnresolvedBinary(metadata);
|
||||
version = metadata.ProductVersion ?? metadata.FileVersion;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Unresolved binary {FilePath}, generated generic PURL: {Purl}",
|
||||
metadata.FilePath,
|
||||
purl);
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(metadata.FilePath);
|
||||
|
||||
return new NativeComponentEmitResult(
|
||||
Purl: purl,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Metadata: metadata,
|
||||
IndexMatch: indexMatch,
|
||||
LookupResult: lookupResult);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<NativeComponentEmitResult>> EmitBatchAsync(
|
||||
IEnumerable<NativeBinaryMetadata> metadataList,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadataList);
|
||||
|
||||
var metadataArray = metadataList.ToArray();
|
||||
if (metadataArray.Length == 0)
|
||||
{
|
||||
return Array.Empty<NativeComponentEmitResult>();
|
||||
}
|
||||
|
||||
// Batch lookup for all Build-IDs
|
||||
var buildIds = metadataArray
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.BuildId))
|
||||
.Select(m => m.BuildId!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var lookupResults = await _buildIdIndex.BatchLookupAsync(buildIds, cancellationToken).ConfigureAwait(false);
|
||||
var lookupMap = lookupResults.ToDictionary(
|
||||
r => r.BuildId,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Batch lookup: {Total} binaries, {Resolved} resolved via index",
|
||||
metadataArray.Length,
|
||||
lookupMap.Count);
|
||||
|
||||
// Emit components
|
||||
var results = new List<NativeComponentEmitResult>(metadataArray.Length);
|
||||
|
||||
foreach (var metadata in metadataArray)
|
||||
{
|
||||
BuildIdLookupResult? lookupResult = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.BuildId) &&
|
||||
lookupMap.TryGetValue(metadata.BuildId, out var result))
|
||||
{
|
||||
lookupResult = result;
|
||||
}
|
||||
|
||||
string purl;
|
||||
string? version = null;
|
||||
bool indexMatch = false;
|
||||
|
||||
if (lookupResult is not null)
|
||||
{
|
||||
purl = _purlBuilder.FromIndexResult(lookupResult);
|
||||
version = lookupResult.Version;
|
||||
indexMatch = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
purl = _purlBuilder.FromUnresolvedBinary(metadata);
|
||||
version = metadata.ProductVersion ?? metadata.FileVersion;
|
||||
}
|
||||
|
||||
results.Add(new NativeComponentEmitResult(
|
||||
Purl: purl,
|
||||
Name: Path.GetFileName(metadata.FilePath),
|
||||
Version: version,
|
||||
Metadata: metadata,
|
||||
IndexMatch: indexMatch,
|
||||
LookupResult: lookupResult));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Builds PURLs for native binaries.
|
||||
/// </summary>
|
||||
public sealed class NativePurlBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a PURL from a Build-ID index lookup result.
|
||||
/// </summary>
|
||||
/// <param name="lookupResult">The index lookup result.</param>
|
||||
/// <returns>PURL string.</returns>
|
||||
public string FromIndexResult(BuildIdLookupResult lookupResult)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lookupResult);
|
||||
return lookupResult.Purl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PURL for an unresolved native binary.
|
||||
/// Falls back to pkg:generic with build-id qualifier.
|
||||
/// </summary>
|
||||
/// <param name="metadata">Binary metadata.</param>
|
||||
/// <returns>PURL string.</returns>
|
||||
public string FromUnresolvedBinary(NativeBinaryMetadata metadata)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
// Extract filename from path
|
||||
var fileName = Path.GetFileName(metadata.FilePath);
|
||||
|
||||
// Build pkg:generic PURL with build-id qualifier
|
||||
var purl = $"pkg:generic/{EncodeComponent(fileName)}@unknown";
|
||||
|
||||
var qualifiers = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.BuildId))
|
||||
{
|
||||
qualifiers.Add($"build-id={EncodeComponent(metadata.BuildId)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Architecture))
|
||||
{
|
||||
qualifiers.Add($"arch={EncodeComponent(metadata.Architecture)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Platform))
|
||||
{
|
||||
qualifiers.Add($"os={EncodeComponent(metadata.Platform)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.FileDigest))
|
||||
{
|
||||
qualifiers.Add($"checksum={EncodeComponent(metadata.FileDigest)}");
|
||||
}
|
||||
|
||||
if (qualifiers.Count > 0)
|
||||
{
|
||||
purl += "?" + string.Join("&", qualifiers.OrderBy(q => q, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
return purl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PURL for a binary with known distro information.
|
||||
/// </summary>
|
||||
/// <param name="distro">Distribution type (deb, rpm, apk, etc.)</param>
|
||||
/// <param name="distroName">Distribution name (debian, fedora, alpine, etc.)</param>
|
||||
/// <param name="packageName">Package name.</param>
|
||||
/// <param name="version">Package version.</param>
|
||||
/// <param name="architecture">CPU architecture.</param>
|
||||
/// <returns>PURL string.</returns>
|
||||
public string FromDistroPackage(
|
||||
string distro,
|
||||
string distroName,
|
||||
string packageName,
|
||||
string version,
|
||||
string? architecture = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(distro);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(distroName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packageName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
// Map distro type to PURL type
|
||||
var purlType = distro.ToLowerInvariant() switch
|
||||
{
|
||||
"deb" or "debian" or "ubuntu" => "deb",
|
||||
"rpm" or "fedora" or "rhel" or "centos" => "rpm",
|
||||
"apk" or "alpine" => "apk",
|
||||
"pacman" or "arch" => "pacman",
|
||||
_ => "generic"
|
||||
};
|
||||
|
||||
var purl = $"pkg:{purlType}/{EncodeComponent(distroName)}/{EncodeComponent(packageName)}@{EncodeComponent(version)}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(architecture))
|
||||
{
|
||||
purl += $"?arch={EncodeComponent(architecture)}";
|
||||
}
|
||||
|
||||
return purl;
|
||||
}
|
||||
|
||||
private static string EncodeComponent(string value)
|
||||
{
|
||||
// PURL percent-encoding: only encode special characters
|
||||
return Uri.EscapeDataString(value)
|
||||
.Replace("%2F", "/", StringComparison.Ordinal) // Allow / in names
|
||||
.Replace("%40", "@", StringComparison.Ordinal); // @ is already version separator
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Builds path witnesses from reachability analysis results.
|
||||
/// </summary>
|
||||
public interface IPathWitnessBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a path witness for a reachable vulnerability.
|
||||
/// </summary>
|
||||
/// <param name="request">The witness creation request containing all necessary context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A signed path witness or null if the path is not reachable.</returns>
|
||||
Task<PathWitness?> BuildAsync(PathWitnessRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple path witnesses for all reachable paths to a vulnerability.
|
||||
/// </summary>
|
||||
/// <param name="request">The batch witness request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>All generated witnesses.</returns>
|
||||
IAsyncEnumerable<PathWitness> BuildAllAsync(BatchWitnessRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build a single path witness.
|
||||
/// </summary>
|
||||
public sealed record PathWitnessRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM digest for artifact context.
|
||||
/// </summary>
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL of the vulnerable component.
|
||||
/// </summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID (e.g., "CVE-2024-12345").
|
||||
/// </summary>
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability source (e.g., "NVD").
|
||||
/// </summary>
|
||||
public required string VulnSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range.
|
||||
/// </summary>
|
||||
public required string AffectedRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint symbol ID.
|
||||
/// </summary>
|
||||
public required string EntrypointSymbolId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint kind (http, grpc, cli, etc.).
|
||||
/// </summary>
|
||||
public required string EntrypointKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable entrypoint name.
|
||||
/// </summary>
|
||||
public required string EntrypointName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink symbol ID.
|
||||
/// </summary>
|
||||
public required string SinkSymbolId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink taxonomy type.
|
||||
/// </summary>
|
||||
public required string SinkType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The call graph to use for path finding.
|
||||
/// </summary>
|
||||
public required RichGraph CallGraph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 digest of the call graph.
|
||||
/// </summary>
|
||||
public required string CallgraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional attack surface digest.
|
||||
/// </summary>
|
||||
public string? SurfaceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional analysis config digest.
|
||||
/// </summary>
|
||||
public string? AnalysisConfigDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional build ID.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build witnesses for all paths to a vulnerability.
|
||||
/// </summary>
|
||||
public sealed record BatchWitnessRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM digest for artifact context.
|
||||
/// </summary>
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL of the vulnerable component.
|
||||
/// </summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability source.
|
||||
/// </summary>
|
||||
public required string VulnSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range.
|
||||
/// </summary>
|
||||
public required string AffectedRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink symbol ID to find paths to.
|
||||
/// </summary>
|
||||
public required string SinkSymbolId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink taxonomy type.
|
||||
/// </summary>
|
||||
public required string SinkType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The call graph to use for path finding.
|
||||
/// </summary>
|
||||
public required RichGraph CallGraph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 digest of the call graph.
|
||||
/// </summary>
|
||||
public required string CallgraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of witnesses to generate.
|
||||
/// </summary>
|
||||
public int MaxWitnesses { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Optional attack surface digest.
|
||||
/// </summary>
|
||||
public string? SurfaceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional analysis config digest.
|
||||
/// </summary>
|
||||
public string? AnalysisConfigDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional build ID.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// A DSSE-signable path witness documenting the call path from entrypoint to vulnerable sink.
|
||||
/// Conforms to stellaops.witness.v1 schema.
|
||||
/// </summary>
|
||||
public sealed record PathWitness
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witness_schema")]
|
||||
public string WitnessSchema { get; init; } = Witnesses.WitnessSchema.Version;
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed witness ID (e.g., "wit:sha256:...").
|
||||
/// </summary>
|
||||
[JsonPropertyName("witness_id")]
|
||||
public required string WitnessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The artifact (SBOM, component) this witness relates to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifact")]
|
||||
public required WitnessArtifact Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerability this witness concerns.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vuln")]
|
||||
public required WitnessVuln Vuln { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The entrypoint from which the path originates.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoint")]
|
||||
public required WitnessEntrypoint Entrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The call path from entrypoint to sink, ordered from caller to callee.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required IReadOnlyList<PathStep> Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerable sink reached at the end of the path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink")]
|
||||
public required WitnessSink Sink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected gates (guards, authentication, validation) along the path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("gates")]
|
||||
public IReadOnlyList<DetectedGate>? Gates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence digests and build context for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public required WitnessEvidence Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this witness was generated (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("observed_at")]
|
||||
public required DateTimeOffset ObservedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact context for a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbom_digest")]
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL of the vulnerable component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("component_purl")]
|
||||
public required string ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability information for a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessVuln
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier (e.g., "CVE-2024-12345").
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability source (e.g., "NVD", "OSV", "GHSA").
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range expression.
|
||||
/// </summary>
|
||||
[JsonPropertyName("affected_range")]
|
||||
public required string AffectedRange { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint that starts the reachability path.
|
||||
/// </summary>
|
||||
public sealed record WitnessEntrypoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Kind of entrypoint (http, grpc, cli, job, event).
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public required string Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name (e.g., "GET /api/users/{id}").
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical symbol ID for the entrypoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol_id")]
|
||||
public required string SymbolId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single step in the call path from entrypoint to sink.
|
||||
/// </summary>
|
||||
public sealed record PathStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable symbol name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical symbol ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol_id")]
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file path (null for external/binary symbols).
|
||||
/// </summary>
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number in source file (1-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Column number in source file (1-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("column")]
|
||||
public int? Column { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerable sink at the end of the reachability path.
|
||||
/// </summary>
|
||||
public sealed record WitnessSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable symbol name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical symbol ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol_id")]
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink taxonomy type (e.g., "deserialization", "sql_injection", "path_traversal").
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink_type")]
|
||||
public required string SinkType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A detected gate (guard/mitigating control) along the path.
|
||||
/// </summary>
|
||||
public sealed record DetectedGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate type (authRequired, inputValidation, rateLimited, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol that implements the gate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("guard_symbol")]
|
||||
public required string GuardSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level (0.0 - 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable detail about the gate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence digests for reproducibility and audit trail.
|
||||
/// </summary>
|
||||
public sealed record WitnessEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// BLAKE3 digest of the call graph used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("callgraph_digest")]
|
||||
public required string CallgraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the attack surface manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("surface_digest")]
|
||||
public string? SurfaceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the analysis configuration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analysis_config_digest")]
|
||||
public string? AnalysisConfigDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build identifier for the analyzed artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("build_id")]
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Builds path witnesses from reachability analysis results.
|
||||
/// </summary>
|
||||
public sealed class PathWitnessBuilder : IPathWitnessBuilder
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly CompositeGateDetector? _gateDetector;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PathWitnessBuilder.
|
||||
/// </summary>
|
||||
/// <param name="cryptoHash">Crypto hash service for witness ID generation.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <param name="gateDetector">Optional gate detector for identifying guards along paths.</param>
|
||||
public PathWitnessBuilder(
|
||||
ICryptoHash cryptoHash,
|
||||
TimeProvider timeProvider,
|
||||
CompositeGateDetector? gateDetector = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_gateDetector = gateDetector;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PathWitness?> BuildAsync(PathWitnessRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Find path from entrypoint to sink using BFS
|
||||
var path = FindPath(request.CallGraph, request.EntrypointSymbolId, request.SinkSymbolId);
|
||||
if (path is null || path.Count == 0)
|
||||
{
|
||||
return null; // No path found
|
||||
}
|
||||
|
||||
// Infer language from the call graph nodes
|
||||
var language = request.CallGraph.Nodes?.FirstOrDefault()?.Lang ?? "unknown";
|
||||
|
||||
// Detect gates along the path
|
||||
var gates = _gateDetector is not null
|
||||
? await DetectGatesAsync(request.CallGraph, path, language, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
// Get sink node info
|
||||
var sinkNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.SymbolId == request.SinkSymbolId);
|
||||
var sinkSymbol = sinkNode?.Display ?? sinkNode?.Symbol?.Demangled ?? request.SinkSymbolId;
|
||||
|
||||
// Build the witness
|
||||
var witness = new PathWitness
|
||||
{
|
||||
WitnessId = string.Empty, // Will be set after hashing
|
||||
Artifact = new WitnessArtifact
|
||||
{
|
||||
SbomDigest = request.SbomDigest,
|
||||
ComponentPurl = request.ComponentPurl
|
||||
},
|
||||
Vuln = new WitnessVuln
|
||||
{
|
||||
Id = request.VulnId,
|
||||
Source = request.VulnSource,
|
||||
AffectedRange = request.AffectedRange
|
||||
},
|
||||
Entrypoint = new WitnessEntrypoint
|
||||
{
|
||||
Kind = request.EntrypointKind,
|
||||
Name = request.EntrypointName,
|
||||
SymbolId = request.EntrypointSymbolId
|
||||
},
|
||||
Path = path,
|
||||
Sink = new WitnessSink
|
||||
{
|
||||
Symbol = sinkSymbol,
|
||||
SymbolId = request.SinkSymbolId,
|
||||
SinkType = request.SinkType
|
||||
},
|
||||
Gates = gates,
|
||||
Evidence = new WitnessEvidence
|
||||
{
|
||||
CallgraphDigest = request.CallgraphDigest,
|
||||
SurfaceDigest = request.SurfaceDigest,
|
||||
AnalysisConfigDigest = request.AnalysisConfigDigest,
|
||||
BuildId = request.BuildId
|
||||
},
|
||||
ObservedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Compute witness ID from canonical content
|
||||
var witnessId = ComputeWitnessId(witness);
|
||||
witness = witness with { WitnessId = witnessId };
|
||||
|
||||
return witness;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<PathWitness> BuildAllAsync(
|
||||
BatchWitnessRequest request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Find all roots (entrypoints) in the graph
|
||||
var roots = request.CallGraph.Roots;
|
||||
if (roots is null || roots.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var witnessCount = 0;
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
if (witnessCount >= request.MaxWitnesses)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Look up the node to get the symbol name
|
||||
var rootNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.Id == root.Id);
|
||||
|
||||
var singleRequest = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = request.SbomDigest,
|
||||
ComponentPurl = request.ComponentPurl,
|
||||
VulnId = request.VulnId,
|
||||
VulnSource = request.VulnSource,
|
||||
AffectedRange = request.AffectedRange,
|
||||
EntrypointSymbolId = rootNode?.SymbolId ?? root.Id,
|
||||
EntrypointKind = root.Phase ?? "unknown",
|
||||
EntrypointName = rootNode?.Display ?? root.Source ?? root.Id,
|
||||
SinkSymbolId = request.SinkSymbolId,
|
||||
SinkType = request.SinkType,
|
||||
CallGraph = request.CallGraph,
|
||||
CallgraphDigest = request.CallgraphDigest,
|
||||
SurfaceDigest = request.SurfaceDigest,
|
||||
AnalysisConfigDigest = request.AnalysisConfigDigest,
|
||||
BuildId = request.BuildId
|
||||
};
|
||||
|
||||
var witness = await BuildAsync(singleRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (witness is not null)
|
||||
{
|
||||
witnessCount++;
|
||||
yield return witness;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the shortest path from source to target using BFS.
|
||||
/// </summary>
|
||||
private List<PathStep>? FindPath(RichGraph graph, string sourceSymbolId, string targetSymbolId)
|
||||
{
|
||||
if (graph.Nodes is null || graph.Edges is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build node ID to symbol ID mapping
|
||||
var nodeIdToSymbolId = graph.Nodes.ToDictionary(
|
||||
n => n.Id,
|
||||
n => n.SymbolId,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Build adjacency list using From/To (node IDs) mapped to symbol IDs
|
||||
var adjacency = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (string.IsNullOrEmpty(edge.From) || string.IsNullOrEmpty(edge.To))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map node IDs to symbol IDs
|
||||
if (!nodeIdToSymbolId.TryGetValue(edge.From, out var fromSymbolId) ||
|
||||
!nodeIdToSymbolId.TryGetValue(edge.To, out var toSymbolId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!adjacency.TryGetValue(fromSymbolId, out var neighbors))
|
||||
{
|
||||
neighbors = new List<string>();
|
||||
adjacency[fromSymbolId] = neighbors;
|
||||
}
|
||||
neighbors.Add(toSymbolId);
|
||||
}
|
||||
|
||||
// BFS to find shortest path
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var parent = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var queue = new Queue<string>();
|
||||
|
||||
queue.Enqueue(sourceSymbolId);
|
||||
visited.Add(sourceSymbolId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
|
||||
if (current.Equals(targetSymbolId, StringComparison.Ordinal))
|
||||
{
|
||||
// Reconstruct path
|
||||
return ReconstructPath(graph, parent, sourceSymbolId, targetSymbolId);
|
||||
}
|
||||
|
||||
if (!adjacency.TryGetValue(current, out var neighbors))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort neighbors for deterministic ordering
|
||||
foreach (var neighbor in neighbors.Order(StringComparer.Ordinal))
|
||||
{
|
||||
if (visited.Add(neighbor))
|
||||
{
|
||||
parent[neighbor] = current;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No path found
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs the path from parent map.
|
||||
/// </summary>
|
||||
private static List<PathStep> ReconstructPath(
|
||||
RichGraph graph,
|
||||
Dictionary<string, string> parent,
|
||||
string source,
|
||||
string target)
|
||||
{
|
||||
var path = new List<PathStep>();
|
||||
var nodeMap = graph.Nodes?.ToDictionary(n => n.SymbolId ?? string.Empty, n => n, StringComparer.Ordinal)
|
||||
?? new Dictionary<string, RichGraphNode>(StringComparer.Ordinal);
|
||||
|
||||
var current = target;
|
||||
while (current is not null)
|
||||
{
|
||||
nodeMap.TryGetValue(current, out var node);
|
||||
|
||||
// Extract source file/line from Attributes if available
|
||||
string? file = null;
|
||||
int? line = null;
|
||||
int? column = null;
|
||||
|
||||
if (node?.Attributes is not null)
|
||||
{
|
||||
if (node.Attributes.TryGetValue("file", out var fileValue))
|
||||
{
|
||||
file = fileValue;
|
||||
}
|
||||
if (node.Attributes.TryGetValue("line", out var lineValue) && int.TryParse(lineValue, out var parsedLine))
|
||||
{
|
||||
line = parsedLine;
|
||||
}
|
||||
if (node.Attributes.TryGetValue("column", out var colValue) && int.TryParse(colValue, out var parsedCol))
|
||||
{
|
||||
column = parsedCol;
|
||||
}
|
||||
}
|
||||
|
||||
path.Add(new PathStep
|
||||
{
|
||||
Symbol = node?.Display ?? node?.Symbol?.Demangled ?? current,
|
||||
SymbolId = current,
|
||||
File = file,
|
||||
Line = line,
|
||||
Column = column
|
||||
});
|
||||
|
||||
if (current.Equals(source, StringComparison.Ordinal))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
parent.TryGetValue(current, out current);
|
||||
}
|
||||
|
||||
path.Reverse(); // Reverse to get source → target order
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects gates along the path using the composite gate detector.
|
||||
/// </summary>
|
||||
private async Task<List<DetectedGate>?> DetectGatesAsync(
|
||||
RichGraph graph,
|
||||
List<PathStep> path,
|
||||
string language,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_gateDetector is null || path.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build source file map for the path
|
||||
var sourceFiles = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var nodeMap = graph.Nodes?.ToDictionary(n => n.SymbolId ?? string.Empty, n => n, StringComparer.Ordinal)
|
||||
?? new Dictionary<string, RichGraphNode>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var step in path)
|
||||
{
|
||||
if (nodeMap.TryGetValue(step.SymbolId, out var node) &&
|
||||
node.Attributes is not null &&
|
||||
node.Attributes.TryGetValue("file", out var file))
|
||||
{
|
||||
sourceFiles[step.SymbolId] = file;
|
||||
}
|
||||
}
|
||||
|
||||
var context = new CallPathContext
|
||||
{
|
||||
CallPath = path.Select(s => s.SymbolId).ToList(),
|
||||
SourceFiles = sourceFiles.Count > 0 ? sourceFiles : null,
|
||||
Language = language
|
||||
};
|
||||
|
||||
var result = await _gateDetector.DetectAllAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Gates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.Gates.Select(g => new DetectedGate
|
||||
{
|
||||
Type = g.Type.ToString(),
|
||||
GuardSymbol = g.GuardSymbol,
|
||||
Confidence = g.Confidence,
|
||||
Detail = g.Detail
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content-addressed witness ID.
|
||||
/// </summary>
|
||||
private string ComputeWitnessId(PathWitness witness)
|
||||
{
|
||||
// Create a canonical representation for hashing (excluding witness_id itself)
|
||||
var canonical = new
|
||||
{
|
||||
witness.WitnessSchema,
|
||||
witness.Artifact,
|
||||
witness.Vuln,
|
||||
witness.Entrypoint,
|
||||
witness.Path,
|
||||
witness.Sink,
|
||||
witness.Evidence
|
||||
};
|
||||
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(canonical, JsonOptions);
|
||||
var hash = _cryptoHash.ComputePrefixedHashForPurpose(json, HashPurpose.Content);
|
||||
|
||||
return $"{WitnessSchema.WitnessIdPrefix}{hash}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for the stellaops.witness.v1 schema.
|
||||
/// </summary>
|
||||
public static class WitnessSchema
|
||||
{
|
||||
/// <summary>
|
||||
/// Current witness schema version.
|
||||
/// </summary>
|
||||
public const string Version = "stellaops.witness.v1";
|
||||
|
||||
/// <summary>
|
||||
/// Prefix for witness IDs.
|
||||
/// </summary>
|
||||
public const string WitnessIdPrefix = "wit:";
|
||||
|
||||
/// <summary>
|
||||
/// Default DSSE payload type for witnesses.
|
||||
/// </summary>
|
||||
public const string DssePayloadType = "application/vnd.stellaops.witness.v1+json";
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BoundaryProof.cs
|
||||
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
|
||||
// Description: Boundary proof model for surface exposure and security controls.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiff.Detection;
|
||||
|
||||
/// <summary>
|
||||
/// Boundary proof describing surface exposure, authentication, and security controls.
|
||||
/// Used to determine the attack surface and protective measures for a finding.
|
||||
/// </summary>
|
||||
public sealed record BoundaryProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Kind of boundary (network, file, ipc, process).
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Surface descriptor (what is exposed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("surface")]
|
||||
public BoundarySurface? Surface { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposure descriptor (how it's exposed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("exposure")]
|
||||
public BoundaryExposure? Exposure { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication requirements.
|
||||
/// </summary>
|
||||
[JsonPropertyName("auth")]
|
||||
public BoundaryAuth? Auth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security controls protecting the boundary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("controls")]
|
||||
public IReadOnlyList<BoundaryControl>? Controls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the boundary was last verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("last_seen")]
|
||||
public DateTimeOffset LastSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for this boundary proof (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of this boundary proof (static_analysis, runtime_observation, config).
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the evidence source (graph hash, scan ID, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_ref")]
|
||||
public string? EvidenceRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes what attack surface is exposed.
|
||||
/// </summary>
|
||||
public sealed record BoundarySurface
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of surface (api, web, cli, library, file, socket).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Protocol (http, https, grpc, tcp, udp, unix).
|
||||
/// </summary>
|
||||
[JsonPropertyName("protocol")]
|
||||
public string? Protocol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Port number if network-exposed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("port")]
|
||||
public int? Port { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Host or interface binding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("host")]
|
||||
public string? Host { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path or route pattern.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes how the surface is exposed.
|
||||
/// </summary>
|
||||
public sealed record BoundaryExposure
|
||||
{
|
||||
/// <summary>
|
||||
/// Exposure level (public, internal, private, localhost).
|
||||
/// </summary>
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exposure is internet-facing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("internet_facing")]
|
||||
public bool InternetFacing { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Network zone (dmz, internal, trusted, untrusted).
|
||||
/// </summary>
|
||||
[JsonPropertyName("zone")]
|
||||
public string? Zone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether behind a load balancer or proxy.
|
||||
/// </summary>
|
||||
[JsonPropertyName("behind_proxy")]
|
||||
public bool? BehindProxy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected client types (browser, api_client, service, any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_types")]
|
||||
public IReadOnlyList<string>? ClientTypes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes authentication requirements at the boundary.
|
||||
/// </summary>
|
||||
public sealed record BoundaryAuth
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether authentication is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication type (jwt, oauth2, basic, api_key, mtls, session).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required roles or scopes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("roles")]
|
||||
public IReadOnlyList<string>? Roles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication provider or issuer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("provider")]
|
||||
public string? Provider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether MFA is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mfa_required")]
|
||||
public bool? MfaRequired { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a security control at the boundary.
|
||||
/// </summary>
|
||||
public sealed record BoundaryControl
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of control (rate_limit, waf, input_validation, output_encoding, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the control is currently active.
|
||||
/// </summary>
|
||||
[JsonPropertyName("active")]
|
||||
public bool Active { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Control configuration or policy reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("config")]
|
||||
public string? Config { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Effectiveness rating (high, medium, low).
|
||||
/// </summary>
|
||||
[JsonPropertyName("effectiveness")]
|
||||
public string? Effectiveness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the control was last verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified_at")]
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexEvidence.cs
|
||||
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
|
||||
// Description: VEX (Vulnerability Exploitability eXchange) evidence model.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiff.Detection;
|
||||
|
||||
/// <summary>
|
||||
/// VEX (Vulnerability Exploitability eXchange) evidence for a vulnerability.
|
||||
/// Captures vendor/first-party statements about whether a vulnerability is exploitable.
|
||||
/// </summary>
|
||||
public sealed record VexEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX status: not_affected, affected, fixed, under_investigation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the status (per OpenVEX specification).
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public VexJustification? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable impact statement explaining why not affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("impact")]
|
||||
public string? Impact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable action statement (remediation steps).
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the VEX document or DSSE attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestation_ref")]
|
||||
public string? AttestationRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("document_id")]
|
||||
public string? DocumentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement was issued.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issued_at")]
|
||||
public DateTimeOffset? IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement was last updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement expires.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the VEX statement (vendor, first_party, third_party, coordinator).
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public VexSource? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected product or component reference (PURL).
|
||||
/// </summary>
|
||||
[JsonPropertyName("product_ref")]
|
||||
public string? ProductRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerability_id")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in the VEX statement (0.0 to 1.0).
|
||||
/// Higher confidence for vendor statements, lower for third-party.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the VEX statement is still valid (not expired).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsValid => ExpiresAt is null || ExpiresAt > DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this VEX statement indicates the vulnerability is not exploitable.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsNotAffected => Status == VexStatus.NotAffected;
|
||||
|
||||
/// <summary>
|
||||
/// Additional context or notes about the VEX statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("notes")]
|
||||
public IReadOnlyList<string>? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX status values per OpenVEX specification.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum VexStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The vulnerability is not exploitable in this context.
|
||||
/// </summary>
|
||||
[JsonPropertyName("not_affected")]
|
||||
NotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerability is exploitable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("affected")]
|
||||
Affected,
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerability has been fixed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixed")]
|
||||
Fixed,
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerability is under investigation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("under_investigation")]
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
// NOTE: VexJustification is defined in VexCandidateModels.cs to avoid duplication
|
||||
|
||||
/// <summary>
|
||||
/// Source of a VEX statement.
|
||||
/// </summary>
|
||||
public sealed record VexSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Source type (vendor, first_party, third_party, coordinator, community).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the source organization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to the source's VEX feed or website.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust level (high, medium, low).
|
||||
/// Vendor and first-party are typically high; third-party varies.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trust_level")]
|
||||
public string? TrustLevel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EpssUpdatedEvent.cs
|
||||
// Sprint: SPRINT_3410_0001_0001_epss_ingestion_storage
|
||||
// Task: EPSS-3410-011
|
||||
// Description: Event published when EPSS data is successfully updated.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Epss.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event published when EPSS data is successfully ingested.
|
||||
/// Event type: "epss.updated@1"
|
||||
/// </summary>
|
||||
public sealed record EpssUpdatedEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event type identifier for routing.
|
||||
/// </summary>
|
||||
public const string EventType = "epss.updated@1";
|
||||
|
||||
/// <summary>
|
||||
/// Event version for schema evolution.
|
||||
/// </summary>
|
||||
public const int Version = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this event instance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("event_id")]
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the event occurred.
|
||||
/// </summary>
|
||||
[JsonPropertyName("occurred_at_utc")]
|
||||
public required DateTimeOffset OccurredAtUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The import run ID that produced this update.
|
||||
/// </summary>
|
||||
[JsonPropertyName("import_run_id")]
|
||||
public required Guid ImportRunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The EPSS model date (YYYY-MM-DD) that was imported.
|
||||
/// </summary>
|
||||
[JsonPropertyName("model_date")]
|
||||
public required DateOnly ModelDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The EPSS model version tag (e.g., "v2025.12.17").
|
||||
/// </summary>
|
||||
[JsonPropertyName("model_version_tag")]
|
||||
public string? ModelVersionTag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The published date from the EPSS data.
|
||||
/// </summary>
|
||||
[JsonPropertyName("published_date")]
|
||||
public DateOnly? PublishedDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of CVEs in the snapshot.
|
||||
/// </summary>
|
||||
[JsonPropertyName("row_count")]
|
||||
public required int RowCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of distinct CVE IDs in the snapshot.
|
||||
/// </summary>
|
||||
[JsonPropertyName("distinct_cve_count")]
|
||||
public required int DistinctCveCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 hash of the decompressed CSV content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content_hash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source URI (online URL or bundle path).
|
||||
/// </summary>
|
||||
[JsonPropertyName("source_uri")]
|
||||
public required string SourceUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the ingestion in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public required long DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of material changes detected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("change_summary")]
|
||||
public EpssChangeSummary? ChangeSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an idempotency key for this event based on model date and import run.
|
||||
/// </summary>
|
||||
public string GetIdempotencyKey()
|
||||
=> $"epss.updated:{ModelDate:yyyy-MM-dd}:{ImportRunId:N}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of material changes in an EPSS update.
|
||||
/// </summary>
|
||||
public sealed record EpssChangeSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of CVEs newly scored (first appearance).
|
||||
/// </summary>
|
||||
[JsonPropertyName("new_scored")]
|
||||
public int NewScored { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of CVEs that crossed the high threshold upward.
|
||||
/// </summary>
|
||||
[JsonPropertyName("crossed_high")]
|
||||
public int CrossedHigh { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of CVEs that crossed the high threshold downward.
|
||||
/// </summary>
|
||||
[JsonPropertyName("crossed_low")]
|
||||
public int CrossedLow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of CVEs with a big jump up in score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("big_jump_up")]
|
||||
public int BigJumpUp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of CVEs with a big jump down in score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("big_jump_down")]
|
||||
public int BigJumpDown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of CVEs that entered the top percentile.
|
||||
/// </summary>
|
||||
[JsonPropertyName("top_percentile")]
|
||||
public int TopPercentile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of CVEs that left the top percentile.
|
||||
/// </summary>
|
||||
[JsonPropertyName("left_top_percentile")]
|
||||
public int LeftTopPercentile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of CVEs with any material change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("total_changed")]
|
||||
public int TotalChanged { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating <see cref="EpssUpdatedEvent"/> instances.
|
||||
/// </summary>
|
||||
public static class EpssUpdatedEventBuilder
|
||||
{
|
||||
public static EpssUpdatedEvent Create(
|
||||
Guid importRunId,
|
||||
DateOnly modelDate,
|
||||
string sourceUri,
|
||||
int rowCount,
|
||||
int distinctCveCount,
|
||||
long durationMs,
|
||||
TimeProvider timeProvider,
|
||||
string? modelVersionTag = null,
|
||||
DateOnly? publishedDate = null,
|
||||
string? contentHash = null,
|
||||
EpssChangeSummary? changeSummary = null)
|
||||
{
|
||||
return new EpssUpdatedEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = timeProvider.GetUtcNow(),
|
||||
ImportRunId = importRunId,
|
||||
ModelDate = modelDate,
|
||||
ModelVersionTag = modelVersionTag,
|
||||
PublishedDate = publishedDate,
|
||||
RowCount = rowCount,
|
||||
DistinctCveCount = distinctCveCount,
|
||||
ContentHash = contentHash,
|
||||
SourceUri = sourceUri,
|
||||
DurationMs = durationMs,
|
||||
ChangeSummary = changeSummary
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,17 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IReachabilityResultRepository, PostgresReachabilityResultRepository>();
|
||||
services.AddScoped<ICodeChangeRepository, PostgresCodeChangeRepository>();
|
||||
services.AddScoped<IReachabilityDriftResultRepository, PostgresReachabilityDriftResultRepository>();
|
||||
|
||||
// EPSS ingestion services
|
||||
services.AddSingleton<EpssCsvStreamParser>();
|
||||
services.AddScoped<IEpssRepository, PostgresEpssRepository>();
|
||||
services.AddSingleton<EpssOnlineSource>();
|
||||
services.AddSingleton<EpssBundleSource>();
|
||||
services.AddSingleton<EpssChangeDetector>();
|
||||
|
||||
// Witness storage (Sprint: SPRINT_3700_0001_0001)
|
||||
services.AddScoped<IWitnessRepository, PostgresWitnessRepository>();
|
||||
|
||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
||||
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Migration: 013_witness_storage.sql
|
||||
-- Sprint: SPRINT_3700_0001_0001_witness_foundation
|
||||
-- Task: WIT-011
|
||||
-- Description: Creates tables for DSSE-signed path witnesses and witness storage.
|
||||
|
||||
-- Witness storage for reachability path proofs
|
||||
CREATE TABLE IF NOT EXISTS scanner.witnesses (
|
||||
witness_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
witness_hash TEXT NOT NULL, -- BLAKE3 hash of witness payload
|
||||
schema_version TEXT NOT NULL DEFAULT 'stellaops.witness.v1',
|
||||
witness_type TEXT NOT NULL, -- 'reachability_path', 'gate_proof', etc.
|
||||
|
||||
-- Reference to the graph/analysis that produced this witness
|
||||
graph_hash TEXT NOT NULL, -- BLAKE3 hash of source rich graph
|
||||
scan_id UUID,
|
||||
run_id UUID,
|
||||
|
||||
-- Witness content
|
||||
payload_json JSONB NOT NULL, -- PathWitness JSON
|
||||
dsse_envelope JSONB, -- DSSE signed envelope (nullable until signed)
|
||||
|
||||
-- Provenance
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
signed_at TIMESTAMPTZ,
|
||||
signer_key_id TEXT,
|
||||
|
||||
-- Indexing
|
||||
entrypoint_fqn TEXT, -- For quick lookup by entrypoint
|
||||
sink_cve TEXT, -- For quick lookup by CVE
|
||||
|
||||
CONSTRAINT uk_witness_hash UNIQUE (witness_hash)
|
||||
);
|
||||
|
||||
-- Index for efficient lookups
|
||||
CREATE INDEX IF NOT EXISTS ix_witnesses_graph_hash ON scanner.witnesses (graph_hash);
|
||||
CREATE INDEX IF NOT EXISTS ix_witnesses_scan_id ON scanner.witnesses (scan_id) WHERE scan_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS ix_witnesses_sink_cve ON scanner.witnesses (sink_cve) WHERE sink_cve IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS ix_witnesses_entrypoint ON scanner.witnesses (entrypoint_fqn) WHERE entrypoint_fqn IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS ix_witnesses_created_at ON scanner.witnesses (created_at DESC);
|
||||
|
||||
-- GIN index for JSONB queries on payload
|
||||
CREATE INDEX IF NOT EXISTS ix_witnesses_payload_gin ON scanner.witnesses USING gin (payload_json jsonb_path_ops);
|
||||
|
||||
-- Witness verification log (for audit trail)
|
||||
CREATE TABLE IF NOT EXISTS scanner.witness_verifications (
|
||||
verification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
witness_id UUID NOT NULL REFERENCES scanner.witnesses(witness_id),
|
||||
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
verified_by TEXT, -- 'system', 'api', 'cli'
|
||||
verification_status TEXT NOT NULL, -- 'valid', 'invalid', 'expired'
|
||||
verification_error TEXT,
|
||||
verifier_key_id TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_witness_verifications_witness_id ON scanner.witness_verifications (witness_id);
|
||||
|
||||
COMMENT ON TABLE scanner.witnesses IS 'DSSE-signed path witnesses for reachability proofs (stellaops.witness.v1)';
|
||||
COMMENT ON TABLE scanner.witness_verifications IS 'Audit log of witness verification attempts';
|
||||
COMMENT ON COLUMN scanner.witnesses.witness_hash IS 'BLAKE3 hash of witness payload for deduplication and integrity';
|
||||
COMMENT ON COLUMN scanner.witnesses.dsse_envelope IS 'Dead Simple Signing Envelope (DSSE) containing the signed witness';
|
||||
@@ -12,4 +12,7 @@ internal static class MigrationIds
|
||||
public const string EpssIntegration = "008_epss_integration.sql";
|
||||
public const string CallGraphTables = "009_call_graph_tables.sql";
|
||||
public const string ReachabilityDriftTables = "010_reachability_drift_tables.sql";
|
||||
public const string EpssRawLayer = "011_epss_raw_layer.sql";
|
||||
public const string EpssSignalLayer = "012_epss_signal_layer.sql";
|
||||
public const string WitnessStorage = "013_witness_storage.sql";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IWitnessRepository.cs
|
||||
// Sprint: SPRINT_3700_0001_0001_witness_foundation
|
||||
// Task: WIT-012
|
||||
// Description: Repository interface for path witness storage and retrieval.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for DSSE-signed path witnesses.
|
||||
/// </summary>
|
||||
public interface IWitnessRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a witness and returns the assigned ID.
|
||||
/// </summary>
|
||||
Task<Guid> StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a witness by its ID.
|
||||
/// </summary>
|
||||
Task<WitnessRecord?> GetByIdAsync(Guid witnessId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a witness by its hash.
|
||||
/// </summary>
|
||||
Task<WitnessRecord?> GetByHashAsync(string witnessHash, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all witnesses for a given graph hash.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WitnessRecord>> GetByGraphHashAsync(string graphHash, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves witnesses for a given scan.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WitnessRecord>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves witnesses for a given CVE.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WitnessRecord>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a witness with a DSSE envelope after signing.
|
||||
/// </summary>
|
||||
Task UpdateDsseEnvelopeAsync(Guid witnessId, string dsseEnvelopeJson, string signerKeyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a verification attempt for a witness.
|
||||
/// </summary>
|
||||
Task RecordVerificationAsync(WitnessVerificationRecord verification, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record representing a stored witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessRecord
|
||||
{
|
||||
public Guid WitnessId { get; init; }
|
||||
public required string WitnessHash { get; init; }
|
||||
public string SchemaVersion { get; init; } = "stellaops.witness.v1";
|
||||
public required string WitnessType { get; init; }
|
||||
public required string GraphHash { get; init; }
|
||||
public Guid? ScanId { get; init; }
|
||||
public Guid? RunId { get; init; }
|
||||
public required string PayloadJson { get; init; }
|
||||
public string? DsseEnvelope { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
public string? SignerKeyId { get; init; }
|
||||
public string? EntrypointFqn { get; init; }
|
||||
public string? SinkCve { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record representing a witness verification attempt.
|
||||
/// </summary>
|
||||
public sealed record WitnessVerificationRecord
|
||||
{
|
||||
public Guid VerificationId { get; init; }
|
||||
public required Guid WitnessId { get; init; }
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
public string? VerifiedBy { get; init; }
|
||||
public required string VerificationStatus { get; init; }
|
||||
public string? VerificationError { get; init; }
|
||||
public string? VerifierKeyId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresWitnessRepository.cs
|
||||
// Sprint: SPRINT_3700_0001_0001_witness_foundation
|
||||
// Task: WIT-012
|
||||
// Description: Postgres implementation of IWitnessRepository for witness storage.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Postgres implementation of <see cref="IWitnessRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresWitnessRepository : IWitnessRepository
|
||||
{
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresWitnessRepository> _logger;
|
||||
|
||||
public PostgresWitnessRepository(ScannerDataSource dataSource, ILogger<PostgresWitnessRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<Guid> StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(witness);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.witnesses (
|
||||
witness_hash, schema_version, witness_type, graph_hash,
|
||||
scan_id, run_id, payload_json, dsse_envelope, created_at,
|
||||
signed_at, signer_key_id, entrypoint_fqn, sink_cve
|
||||
) VALUES (
|
||||
@witness_hash, @schema_version, @witness_type, @graph_hash,
|
||||
@scan_id, @run_id, @payload_json::jsonb, @dsse_envelope::jsonb, @created_at,
|
||||
@signed_at, @signer_key_id, @entrypoint_fqn, @sink_cve
|
||||
)
|
||||
ON CONFLICT (witness_hash) DO UPDATE SET
|
||||
dsse_envelope = COALESCE(EXCLUDED.dsse_envelope, scanner.witnesses.dsse_envelope),
|
||||
signed_at = COALESCE(EXCLUDED.signed_at, scanner.witnesses.signed_at),
|
||||
signer_key_id = COALESCE(EXCLUDED.signer_key_id, scanner.witnesses.signer_key_id)
|
||||
RETURNING witness_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("witness_hash", witness.WitnessHash);
|
||||
cmd.Parameters.AddWithValue("schema_version", witness.SchemaVersion);
|
||||
cmd.Parameters.AddWithValue("witness_type", witness.WitnessType);
|
||||
cmd.Parameters.AddWithValue("graph_hash", witness.GraphHash);
|
||||
cmd.Parameters.AddWithValue("scan_id", witness.ScanId.HasValue ? witness.ScanId.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("run_id", witness.RunId.HasValue ? witness.RunId.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("payload_json", witness.PayloadJson);
|
||||
cmd.Parameters.AddWithValue("dsse_envelope", string.IsNullOrEmpty(witness.DsseEnvelope) ? DBNull.Value : witness.DsseEnvelope);
|
||||
cmd.Parameters.AddWithValue("created_at", witness.CreatedAt == default ? DateTimeOffset.UtcNow : witness.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("signed_at", witness.SignedAt.HasValue ? witness.SignedAt.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(witness.SignerKeyId) ? DBNull.Value : witness.SignerKeyId);
|
||||
cmd.Parameters.AddWithValue("entrypoint_fqn", string.IsNullOrEmpty(witness.EntrypointFqn) ? DBNull.Value : witness.EntrypointFqn);
|
||||
cmd.Parameters.AddWithValue("sink_cve", string.IsNullOrEmpty(witness.SinkCve) ? DBNull.Value : witness.SinkCve);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
var witnessId = (Guid)result!;
|
||||
|
||||
_logger.LogDebug("Stored witness {WitnessId} with hash {WitnessHash}", witnessId, witness.WitnessHash);
|
||||
return witnessId;
|
||||
}
|
||||
|
||||
public async Task<WitnessRecord?> GetByIdAsync(Guid witnessId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
|
||||
scan_id, run_id, payload_json, dsse_envelope, created_at,
|
||||
signed_at, signer_key_id, entrypoint_fqn, sink_cve
|
||||
FROM scanner.witnesses
|
||||
WHERE witness_id = @witness_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("witness_id", witnessId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapToRecord(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<WitnessRecord?> GetByHashAsync(string witnessHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(witnessHash);
|
||||
|
||||
const string sql = """
|
||||
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
|
||||
scan_id, run_id, payload_json, dsse_envelope, created_at,
|
||||
signed_at, signer_key_id, entrypoint_fqn, sink_cve
|
||||
FROM scanner.witnesses
|
||||
WHERE witness_hash = @witness_hash
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("witness_hash", witnessHash);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapToRecord(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WitnessRecord>> GetByGraphHashAsync(string graphHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
|
||||
|
||||
const string sql = """
|
||||
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
|
||||
scan_id, run_id, payload_json, dsse_envelope, created_at,
|
||||
signed_at, signer_key_id, entrypoint_fqn, sink_cve
|
||||
FROM scanner.witnesses
|
||||
WHERE graph_hash = @graph_hash
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("graph_hash", graphHash);
|
||||
|
||||
var results = new List<WitnessRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapToRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WitnessRecord>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
|
||||
scan_id, run_id, payload_json, dsse_envelope, created_at,
|
||||
signed_at, signer_key_id, entrypoint_fqn, sink_cve
|
||||
FROM scanner.witnesses
|
||||
WHERE scan_id = @scan_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("scan_id", scanId);
|
||||
|
||||
var results = new List<WitnessRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapToRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WitnessRecord>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
|
||||
const string sql = """
|
||||
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
|
||||
scan_id, run_id, payload_json, dsse_envelope, created_at,
|
||||
signed_at, signer_key_id, entrypoint_fqn, sink_cve
|
||||
FROM scanner.witnesses
|
||||
WHERE sink_cve = @sink_cve
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("sink_cve", cveId);
|
||||
|
||||
var results = new List<WitnessRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapToRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task UpdateDsseEnvelopeAsync(Guid witnessId, string dsseEnvelopeJson, string signerKeyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dsseEnvelopeJson);
|
||||
|
||||
const string sql = """
|
||||
UPDATE scanner.witnesses
|
||||
SET dsse_envelope = @dsse_envelope::jsonb,
|
||||
signed_at = @signed_at,
|
||||
signer_key_id = @signer_key_id
|
||||
WHERE witness_id = @witness_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("witness_id", witnessId);
|
||||
cmd.Parameters.AddWithValue("dsse_envelope", dsseEnvelopeJson);
|
||||
cmd.Parameters.AddWithValue("signed_at", DateTimeOffset.UtcNow);
|
||||
cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(signerKeyId) ? DBNull.Value : signerKeyId);
|
||||
|
||||
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (affected > 0)
|
||||
{
|
||||
_logger.LogDebug("Updated DSSE envelope for witness {WitnessId}", witnessId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecordVerificationAsync(WitnessVerificationRecord verification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(verification);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.witness_verifications (
|
||||
witness_id, verified_at, verified_by, verification_status,
|
||||
verification_error, verifier_key_id
|
||||
) VALUES (
|
||||
@witness_id, @verified_at, @verified_by, @verification_status,
|
||||
@verification_error, @verifier_key_id
|
||||
)
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("witness_id", verification.WitnessId);
|
||||
cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? DateTimeOffset.UtcNow : verification.VerifiedAt);
|
||||
cmd.Parameters.AddWithValue("verified_by", string.IsNullOrEmpty(verification.VerifiedBy) ? DBNull.Value : verification.VerifiedBy);
|
||||
cmd.Parameters.AddWithValue("verification_status", verification.VerificationStatus);
|
||||
cmd.Parameters.AddWithValue("verification_error", string.IsNullOrEmpty(verification.VerificationError) ? DBNull.Value : verification.VerificationError);
|
||||
cmd.Parameters.AddWithValue("verifier_key_id", string.IsNullOrEmpty(verification.VerifierKeyId) ? DBNull.Value : verification.VerifierKeyId);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Recorded verification for witness {WitnessId}: {Status}", verification.WitnessId, verification.VerificationStatus);
|
||||
}
|
||||
|
||||
private static WitnessRecord MapToRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
return new WitnessRecord
|
||||
{
|
||||
WitnessId = reader.GetGuid(0),
|
||||
WitnessHash = reader.GetString(1),
|
||||
SchemaVersion = reader.GetString(2),
|
||||
WitnessType = reader.GetString(3),
|
||||
GraphHash = reader.GetString(4),
|
||||
ScanId = reader.IsDBNull(5) ? null : reader.GetGuid(5),
|
||||
RunId = reader.IsDBNull(6) ? null : reader.GetGuid(6),
|
||||
PayloadJson = reader.GetString(7),
|
||||
DsseEnvelope = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
CreatedAt = reader.GetDateTime(9),
|
||||
SignedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10),
|
||||
SignerKeyId = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
EntrypointFqn = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
SinkCve = reader.IsDBNull(13) ? null : reader.GetString(13)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only view representing the current state of a triage case,
|
||||
/// combining the latest risk, reachability, and VEX data.
|
||||
/// </summary>
|
||||
[Keyless]
|
||||
public sealed class TriageCaseCurrent
|
||||
{
|
||||
/// <summary>
|
||||
/// The case/finding ID.
|
||||
/// </summary>
|
||||
[Column("case_id")]
|
||||
public Guid CaseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The asset ID.
|
||||
/// </summary>
|
||||
[Column("asset_id")]
|
||||
public Guid AssetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional environment ID.
|
||||
/// </summary>
|
||||
[Column("environment_id")]
|
||||
public Guid? EnvironmentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable asset label.
|
||||
/// </summary>
|
||||
[Column("asset_label")]
|
||||
public string AssetLabel { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package URL of the affected component.
|
||||
/// </summary>
|
||||
[Column("purl")]
|
||||
public string Purl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier (if vulnerability finding).
|
||||
/// </summary>
|
||||
[Column("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule identifier (if policy rule finding).
|
||||
/// </summary>
|
||||
[Column("rule_id")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was first seen.
|
||||
/// </summary>
|
||||
[Column("first_seen_at")]
|
||||
public DateTimeOffset FirstSeenAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was last seen.
|
||||
/// </summary>
|
||||
[Column("last_seen_at")]
|
||||
public DateTimeOffset LastSeenAt { get; init; }
|
||||
|
||||
// Latest risk result fields
|
||||
|
||||
/// <summary>
|
||||
/// Policy ID from latest risk evaluation.
|
||||
/// </summary>
|
||||
[Column("policy_id")]
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version from latest risk evaluation.
|
||||
/// </summary>
|
||||
[Column("policy_version")]
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inputs hash from latest risk evaluation.
|
||||
/// </summary>
|
||||
[Column("inputs_hash")]
|
||||
public string? InputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk score (0-100).
|
||||
/// </summary>
|
||||
[Column("score")]
|
||||
public int? Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final verdict.
|
||||
/// </summary>
|
||||
[Column("verdict")]
|
||||
public TriageVerdict? Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current triage lane.
|
||||
/// </summary>
|
||||
[Column("lane")]
|
||||
public TriageLane? Lane { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Short narrative explaining the current state.
|
||||
/// </summary>
|
||||
[Column("why")]
|
||||
public string? Why { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the risk was last computed.
|
||||
/// </summary>
|
||||
[Column("risk_computed_at")]
|
||||
public DateTimeOffset? RiskComputedAt { get; init; }
|
||||
|
||||
// Latest reachability fields
|
||||
|
||||
/// <summary>
|
||||
/// Reachability determination.
|
||||
/// </summary>
|
||||
[Column("reachable")]
|
||||
public TriageReachability Reachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability confidence (0-100).
|
||||
/// </summary>
|
||||
[Column("reach_confidence")]
|
||||
public short? ReachConfidence { get; init; }
|
||||
|
||||
// Latest VEX fields
|
||||
|
||||
/// <summary>
|
||||
/// VEX status.
|
||||
/// </summary>
|
||||
[Column("vex_status")]
|
||||
public TriageVexStatus? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX issuer.
|
||||
/// </summary>
|
||||
[Column("vex_issuer")]
|
||||
public string? VexIssuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX signature reference.
|
||||
/// </summary>
|
||||
[Column("vex_signature_ref")]
|
||||
public string? VexSignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX source domain.
|
||||
/// </summary>
|
||||
[Column("vex_source_domain")]
|
||||
public string? VexSourceDomain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX source reference.
|
||||
/// </summary>
|
||||
[Column("vex_source_ref")]
|
||||
public string? VexSourceRef { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Signed triage decision (mute, ack, exception). Decisions are reversible via revocation.
|
||||
/// </summary>
|
||||
[Table("triage_decision")]
|
||||
public sealed class TriageDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this decision applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of decision.
|
||||
/// </summary>
|
||||
[Column("kind")]
|
||||
public TriageDecisionKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason code for the decision (from a controlled vocabulary).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("reason_code")]
|
||||
public required string ReasonCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional freeform note from the decision maker.
|
||||
/// </summary>
|
||||
[Column("note")]
|
||||
public string? Note { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy that allowed this decision.
|
||||
/// </summary>
|
||||
[Column("policy_ref")]
|
||||
public string? PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for the decision (null = indefinite).
|
||||
/// </summary>
|
||||
[Column("ttl")]
|
||||
public DateTimeOffset? Ttl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority subject (sub) of the actor who made the decision.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("actor_subject")]
|
||||
public required string ActorSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the actor.
|
||||
/// </summary>
|
||||
[Column("actor_display")]
|
||||
public string? ActorDisplay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to DSSE signature.
|
||||
/// </summary>
|
||||
[Column("signature_ref")]
|
||||
public string? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the DSSE envelope.
|
||||
/// </summary>
|
||||
[Column("dsse_hash")]
|
||||
public string? DsseHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was revoked (null = active).
|
||||
/// </summary>
|
||||
[Column("revoked_at")]
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
[Column("revoke_reason")]
|
||||
public string? RevokeReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature reference for revocation.
|
||||
/// </summary>
|
||||
[Column("revoke_signature_ref")]
|
||||
public string? RevokeSignatureRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE hash for revocation.
|
||||
/// </summary>
|
||||
[Column("revoke_dsse_hash")]
|
||||
public string? RevokeDsseHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this decision is currently active.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsActive => RevokedAt is null;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Effective VEX status for a finding after merging multiple VEX sources.
|
||||
/// Preserves provenance pointers for auditability.
|
||||
/// </summary>
|
||||
[Table("triage_effective_vex")]
|
||||
public sealed class TriageEffectiveVex
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this VEX status applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The effective VEX status after merging.
|
||||
/// </summary>
|
||||
[Column("status")]
|
||||
public TriageVexStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source domain that provided this VEX (e.g., "excititor").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("source_domain")]
|
||||
public required string SourceDomain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable reference string to the source document.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("source_ref")]
|
||||
public required string SourceRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Array of pruned VEX sources with reasons (for merge transparency).
|
||||
/// </summary>
|
||||
[Column("pruned_sources", TypeName = "jsonb")]
|
||||
public string? PrunedSourcesJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the DSSE envelope if signed.
|
||||
/// </summary>
|
||||
[Column("dsse_envelope_hash")]
|
||||
public string? DsseEnvelopeHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Rekor/ledger entry for signature verification.
|
||||
/// </summary>
|
||||
[Column("signature_ref")]
|
||||
public string? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer of the VEX document.
|
||||
/// </summary>
|
||||
[Column("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this VEX status became valid.
|
||||
/// </summary>
|
||||
[Column("valid_from")]
|
||||
public DateTimeOffset ValidFrom { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// When this VEX status expires (null = indefinite).
|
||||
/// </summary>
|
||||
[Column("valid_to")]
|
||||
public DateTimeOffset? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this record was collected.
|
||||
/// </summary>
|
||||
[Column("collected_at")]
|
||||
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Triage lane indicating the current workflow state of a finding.
|
||||
/// </summary>
|
||||
public enum TriageLane
|
||||
{
|
||||
/// <summary>Finding is actively being evaluated.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Finding is blocking shipment.</summary>
|
||||
Blocked,
|
||||
|
||||
/// <summary>Finding requires a security exception to proceed.</summary>
|
||||
NeedsException,
|
||||
|
||||
/// <summary>Finding is muted due to reachability analysis (not reachable).</summary>
|
||||
MutedReach,
|
||||
|
||||
/// <summary>Finding is muted due to VEX status (not affected).</summary>
|
||||
MutedVex,
|
||||
|
||||
/// <summary>Finding is mitigated by compensating controls.</summary>
|
||||
Compensated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final verdict for a triage case.
|
||||
/// </summary>
|
||||
public enum TriageVerdict
|
||||
{
|
||||
/// <summary>Can ship - no blocking issues.</summary>
|
||||
Ship,
|
||||
|
||||
/// <summary>Cannot ship - blocking issues present.</summary>
|
||||
Block,
|
||||
|
||||
/// <summary>Exception granted - can ship with documented exception.</summary>
|
||||
Exception
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability determination result.
|
||||
/// </summary>
|
||||
public enum TriageReachability
|
||||
{
|
||||
/// <summary>Vulnerable code is reachable.</summary>
|
||||
Yes,
|
||||
|
||||
/// <summary>Vulnerable code is not reachable.</summary>
|
||||
No,
|
||||
|
||||
/// <summary>Reachability cannot be determined.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX status per OpenVEX specification.
|
||||
/// </summary>
|
||||
public enum TriageVexStatus
|
||||
{
|
||||
/// <summary>Product is affected by the vulnerability.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Product is not affected by the vulnerability.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Investigation is ongoing.</summary>
|
||||
UnderInvestigation,
|
||||
|
||||
/// <summary>Status is unknown.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of triage decision.
|
||||
/// </summary>
|
||||
public enum TriageDecisionKind
|
||||
{
|
||||
/// <summary>Mute based on reachability analysis.</summary>
|
||||
MuteReach,
|
||||
|
||||
/// <summary>Mute based on VEX status.</summary>
|
||||
MuteVex,
|
||||
|
||||
/// <summary>Acknowledge the finding without action.</summary>
|
||||
Ack,
|
||||
|
||||
/// <summary>Grant a security exception.</summary>
|
||||
Exception
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger that caused a triage snapshot to be created.
|
||||
/// </summary>
|
||||
public enum TriageSnapshotTrigger
|
||||
{
|
||||
/// <summary>Vulnerability feed was updated.</summary>
|
||||
FeedUpdate,
|
||||
|
||||
/// <summary>VEX document was updated.</summary>
|
||||
VexUpdate,
|
||||
|
||||
/// <summary>SBOM was updated.</summary>
|
||||
SbomUpdate,
|
||||
|
||||
/// <summary>Runtime trace was received.</summary>
|
||||
RuntimeTrace,
|
||||
|
||||
/// <summary>Policy was updated.</summary>
|
||||
PolicyUpdate,
|
||||
|
||||
/// <summary>A triage decision was made.</summary>
|
||||
Decision,
|
||||
|
||||
/// <summary>Manual rescan was triggered.</summary>
|
||||
Rescan
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence artifact attached to a finding.
|
||||
/// </summary>
|
||||
public enum TriageEvidenceType
|
||||
{
|
||||
/// <summary>Slice of the SBOM relevant to the finding.</summary>
|
||||
SbomSlice,
|
||||
|
||||
/// <summary>VEX document.</summary>
|
||||
VexDoc,
|
||||
|
||||
/// <summary>Build provenance attestation.</summary>
|
||||
Provenance,
|
||||
|
||||
/// <summary>Callstack or callgraph slice.</summary>
|
||||
CallstackSlice,
|
||||
|
||||
/// <summary>Reachability proof document.</summary>
|
||||
ReachabilityProof,
|
||||
|
||||
/// <summary>Replay manifest for deterministic reproduction.</summary>
|
||||
ReplayManifest,
|
||||
|
||||
/// <summary>Policy document that was applied.</summary>
|
||||
Policy,
|
||||
|
||||
/// <summary>Scan log output.</summary>
|
||||
ScanLog,
|
||||
|
||||
/// <summary>Other evidence type.</summary>
|
||||
Other
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence artifact attached to a finding. Hash-addressed and optionally signed.
|
||||
/// </summary>
|
||||
[Table("triage_evidence_artifact")]
|
||||
public sealed class TriageEvidenceArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this evidence applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence.
|
||||
/// </summary>
|
||||
[Column("type")]
|
||||
public TriageEvidenceType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable title for the evidence.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("title")]
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer of the evidence (if applicable).
|
||||
/// </summary>
|
||||
[Column("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the evidence is cryptographically signed.
|
||||
/// </summary>
|
||||
[Column("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entity that signed the evidence.
|
||||
/// </summary>
|
||||
[Column("signed_by")]
|
||||
public string? SignedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable hash of the artifact.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("content_hash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the signature.
|
||||
/// </summary>
|
||||
[Column("signature_ref")]
|
||||
public string? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type of the artifact.
|
||||
/// </summary>
|
||||
[Column("media_type")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI to the artifact (object store, file path, or inline reference).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("uri")]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the artifact in bytes.
|
||||
/// </summary>
|
||||
[Column("size_bytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata (JSON).
|
||||
/// </summary>
|
||||
[Column("metadata", TypeName = "jsonb")]
|
||||
public string? MetadataJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this artifact was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a triage finding (case). This is the core entity that ties
|
||||
/// together all triage-related data for a specific vulnerability/rule
|
||||
/// on a specific asset.
|
||||
/// </summary>
|
||||
[Table("triage_finding")]
|
||||
public sealed class TriageFinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the finding (also serves as the case ID).
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The asset this finding applies to.
|
||||
/// </summary>
|
||||
[Column("asset_id")]
|
||||
public Guid AssetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional environment identifier (e.g., prod, staging).
|
||||
/// </summary>
|
||||
[Column("environment_id")]
|
||||
public Guid? EnvironmentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable asset label (e.g., "prod/api-gateway:1.2.3").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("asset_label")]
|
||||
public required string AssetLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL identifying the affected component.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier if this is a vulnerability finding.
|
||||
/// </summary>
|
||||
[Column("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule identifier if this is a policy rule finding.
|
||||
/// </summary>
|
||||
[Column("rule_id")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was first observed.
|
||||
/// </summary>
|
||||
[Column("first_seen_at")]
|
||||
public DateTimeOffset FirstSeenAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was last observed.
|
||||
/// </summary>
|
||||
[Column("last_seen_at")]
|
||||
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation properties
|
||||
public ICollection<TriageEffectiveVex> EffectiveVexRecords { get; init; } = new List<TriageEffectiveVex>();
|
||||
public ICollection<TriageReachabilityResult> ReachabilityResults { get; init; } = new List<TriageReachabilityResult>();
|
||||
public ICollection<TriageRiskResult> RiskResults { get; init; } = new List<TriageRiskResult>();
|
||||
public ICollection<TriageDecision> Decisions { get; init; } = new List<TriageDecision>();
|
||||
public ICollection<TriageEvidenceArtifact> EvidenceArtifacts { get; init; } = new List<TriageEvidenceArtifact>();
|
||||
public ICollection<TriageSnapshot> Snapshots { get; init; } = new List<TriageSnapshot>();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis result for a finding.
|
||||
/// </summary>
|
||||
[Table("triage_reachability_result")]
|
||||
public sealed class TriageReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this reachability result applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability determination (Yes, No, Unknown).
|
||||
/// </summary>
|
||||
[Column("reachable")]
|
||||
public TriageReachability Reachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level (0-100).
|
||||
/// </summary>
|
||||
[Column("confidence")]
|
||||
[Range(0, 100)]
|
||||
public short Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to static analysis proof (callgraph slice, CFG slice).
|
||||
/// </summary>
|
||||
[Column("static_proof_ref")]
|
||||
public string? StaticProofRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to runtime proof (runtime trace hits).
|
||||
/// </summary>
|
||||
[Column("runtime_proof_ref")]
|
||||
public string? RuntimeProofRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the inputs used to compute reachability (for caching/diffing).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("inputs_hash")]
|
||||
public required string InputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this result was computed.
|
||||
/// </summary>
|
||||
[Column("computed_at")]
|
||||
public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Risk/lattice result from the scanner's policy evaluation.
|
||||
/// </summary>
|
||||
[Table("triage_risk_result")]
|
||||
public sealed class TriageRiskResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this risk result applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The policy that was applied.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("policy_id")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the policy that was applied.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("policy_version")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the inputs used for this evaluation.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("inputs_hash")]
|
||||
public required string InputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed risk score (0-100).
|
||||
/// </summary>
|
||||
[Column("score")]
|
||||
[Range(0, 100)]
|
||||
public int Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final verdict (Ship, Block, Exception).
|
||||
/// </summary>
|
||||
[Column("verdict")]
|
||||
public TriageVerdict Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current lane based on policy evaluation.
|
||||
/// </summary>
|
||||
[Column("lane")]
|
||||
public TriageLane Lane { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Short narrative explaining the decision.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("why")]
|
||||
public required string Why { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Structured lattice explanation for UI diffing (JSON).
|
||||
/// </summary>
|
||||
[Column("explanation", TypeName = "jsonb")]
|
||||
public string? ExplanationJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this result was computed.
|
||||
/// </summary>
|
||||
[Column("computed_at")]
|
||||
public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot record for Smart-Diff, capturing input/output changes.
|
||||
/// </summary>
|
||||
[Table("triage_snapshot")]
|
||||
public sealed class TriageSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this snapshot applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What triggered this snapshot.
|
||||
/// </summary>
|
||||
[Column("trigger")]
|
||||
public TriageSnapshotTrigger Trigger { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous inputs hash (null for first snapshot).
|
||||
/// </summary>
|
||||
[Column("from_inputs_hash")]
|
||||
public string? FromInputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New inputs hash.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("to_inputs_hash")]
|
||||
public required string ToInputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of what changed.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("summary")]
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Precomputed diff in JSON format (optional).
|
||||
/// </summary>
|
||||
[Column("diff_json", TypeName = "jsonb")]
|
||||
public string? DiffJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this snapshot was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
-- Stella Ops Triage Schema Migration
|
||||
-- Generated from docs/db/triage_schema.sql
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- Enums
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_lane') THEN
|
||||
CREATE TYPE triage_lane AS ENUM (
|
||||
'ACTIVE',
|
||||
'BLOCKED',
|
||||
'NEEDS_EXCEPTION',
|
||||
'MUTED_REACH',
|
||||
'MUTED_VEX',
|
||||
'COMPENSATED'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_verdict') THEN
|
||||
CREATE TYPE triage_verdict AS ENUM ('SHIP', 'BLOCK', 'EXCEPTION');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_reachability') THEN
|
||||
CREATE TYPE triage_reachability AS ENUM ('YES', 'NO', 'UNKNOWN');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_vex_status') THEN
|
||||
CREATE TYPE triage_vex_status AS ENUM ('affected', 'not_affected', 'under_investigation', 'unknown');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_decision_kind') THEN
|
||||
CREATE TYPE triage_decision_kind AS ENUM ('MUTE_REACH', 'MUTE_VEX', 'ACK', 'EXCEPTION');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_snapshot_trigger') THEN
|
||||
CREATE TYPE triage_snapshot_trigger AS ENUM (
|
||||
'FEED_UPDATE',
|
||||
'VEX_UPDATE',
|
||||
'SBOM_UPDATE',
|
||||
'RUNTIME_TRACE',
|
||||
'POLICY_UPDATE',
|
||||
'DECISION',
|
||||
'RESCAN'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_evidence_type') THEN
|
||||
CREATE TYPE triage_evidence_type AS ENUM (
|
||||
'SBOM_SLICE',
|
||||
'VEX_DOC',
|
||||
'PROVENANCE',
|
||||
'CALLSTACK_SLICE',
|
||||
'REACHABILITY_PROOF',
|
||||
'REPLAY_MANIFEST',
|
||||
'POLICY',
|
||||
'SCAN_LOG',
|
||||
'OTHER'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Core: finding (caseId == findingId)
|
||||
CREATE TABLE IF NOT EXISTS triage_finding (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
asset_id uuid NOT NULL,
|
||||
environment_id uuid NULL,
|
||||
asset_label text NOT NULL,
|
||||
purl text NOT NULL,
|
||||
cve_id text NULL,
|
||||
rule_id text NULL,
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (asset_id, environment_id, purl, cve_id, rule_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_last_seen ON triage_finding (last_seen_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_asset_label ON triage_finding (asset_label);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_purl ON triage_finding (purl);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_cve ON triage_finding (cve_id);
|
||||
|
||||
-- Effective VEX (post-merge)
|
||||
CREATE TABLE IF NOT EXISTS triage_effective_vex (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
status triage_vex_status NOT NULL,
|
||||
source_domain text NOT NULL,
|
||||
source_ref text NOT NULL,
|
||||
pruned_sources jsonb NULL,
|
||||
dsse_envelope_hash text NULL,
|
||||
signature_ref text NULL,
|
||||
issuer text NULL,
|
||||
valid_from timestamptz NOT NULL DEFAULT now(),
|
||||
valid_to timestamptz NULL,
|
||||
collected_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_effective_vex_finding ON triage_effective_vex (finding_id, collected_at DESC);
|
||||
|
||||
-- Reachability results
|
||||
CREATE TABLE IF NOT EXISTS triage_reachability_result (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
reachable triage_reachability NOT NULL,
|
||||
confidence smallint NOT NULL CHECK (confidence >= 0 AND confidence <= 100),
|
||||
static_proof_ref text NULL,
|
||||
runtime_proof_ref text NULL,
|
||||
inputs_hash text NOT NULL,
|
||||
computed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_reachability_finding ON triage_reachability_result (finding_id, computed_at DESC);
|
||||
|
||||
-- Risk/lattice result
|
||||
CREATE TABLE IF NOT EXISTS triage_risk_result (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
policy_id text NOT NULL,
|
||||
policy_version text NOT NULL,
|
||||
inputs_hash text NOT NULL,
|
||||
score int NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||
verdict triage_verdict NOT NULL,
|
||||
lane triage_lane NOT NULL,
|
||||
why text NOT NULL,
|
||||
explanation jsonb NULL,
|
||||
computed_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, policy_id, policy_version, inputs_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_risk_finding ON triage_risk_result (finding_id, computed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_risk_lane ON triage_risk_result (lane, computed_at DESC);
|
||||
|
||||
-- Signed Decisions
|
||||
CREATE TABLE IF NOT EXISTS triage_decision (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
kind triage_decision_kind NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
note text NULL,
|
||||
policy_ref text NULL,
|
||||
ttl timestamptz NULL,
|
||||
actor_subject text NOT NULL,
|
||||
actor_display text NULL,
|
||||
signature_ref text NULL,
|
||||
dsse_hash text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz NULL,
|
||||
revoke_reason text NULL,
|
||||
revoke_signature_ref text NULL,
|
||||
revoke_dsse_hash text NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_finding ON triage_decision (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_kind ON triage_decision (kind, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_active ON triage_decision (finding_id) WHERE revoked_at IS NULL;
|
||||
|
||||
-- Evidence artifacts
|
||||
CREATE TABLE IF NOT EXISTS triage_evidence_artifact (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
type triage_evidence_type NOT NULL,
|
||||
title text NOT NULL,
|
||||
issuer text NULL,
|
||||
signed boolean NOT NULL DEFAULT false,
|
||||
signed_by text NULL,
|
||||
content_hash text NOT NULL,
|
||||
signature_ref text NULL,
|
||||
media_type text NULL,
|
||||
uri text NOT NULL,
|
||||
size_bytes bigint NULL,
|
||||
metadata jsonb NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, type, content_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_evidence_finding ON triage_evidence_artifact (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_evidence_type ON triage_evidence_artifact (type, created_at DESC);
|
||||
|
||||
-- Snapshots for Smart-Diff
|
||||
CREATE TABLE IF NOT EXISTS triage_snapshot (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
trigger triage_snapshot_trigger NOT NULL,
|
||||
from_inputs_hash text NULL,
|
||||
to_inputs_hash text NOT NULL,
|
||||
summary text NOT NULL,
|
||||
diff_json jsonb NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, to_inputs_hash, created_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_finding ON triage_snapshot (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_trigger ON triage_snapshot (trigger, created_at DESC);
|
||||
|
||||
-- Current-case view
|
||||
CREATE OR REPLACE VIEW v_triage_case_current AS
|
||||
WITH latest_risk AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, policy_id, policy_version, inputs_hash, score, verdict, lane, why, computed_at
|
||||
FROM triage_risk_result
|
||||
ORDER BY finding_id, computed_at DESC
|
||||
),
|
||||
latest_reach AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, reachable, confidence, static_proof_ref, runtime_proof_ref, computed_at
|
||||
FROM triage_reachability_result
|
||||
ORDER BY finding_id, computed_at DESC
|
||||
),
|
||||
latest_vex AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, status, issuer, signature_ref, source_domain, source_ref, collected_at
|
||||
FROM triage_effective_vex
|
||||
ORDER BY finding_id, collected_at DESC
|
||||
)
|
||||
SELECT
|
||||
f.id AS case_id,
|
||||
f.asset_id,
|
||||
f.environment_id,
|
||||
f.asset_label,
|
||||
f.purl,
|
||||
f.cve_id,
|
||||
f.rule_id,
|
||||
f.first_seen_at,
|
||||
f.last_seen_at,
|
||||
r.policy_id,
|
||||
r.policy_version,
|
||||
r.inputs_hash,
|
||||
r.score,
|
||||
r.verdict,
|
||||
r.lane,
|
||||
r.why,
|
||||
r.computed_at AS risk_computed_at,
|
||||
coalesce(re.reachable, 'UNKNOWN'::triage_reachability) AS reachable,
|
||||
re.confidence AS reach_confidence,
|
||||
v.status AS vex_status,
|
||||
v.issuer AS vex_issuer,
|
||||
v.signature_ref AS vex_signature_ref,
|
||||
v.source_domain AS vex_source_domain,
|
||||
v.source_ref AS vex_source_ref
|
||||
FROM triage_finding f
|
||||
LEFT JOIN latest_risk r ON r.finding_id = f.id
|
||||
LEFT JOIN latest_reach re ON re.finding_id = f.id
|
||||
LEFT JOIN latest_vex v ON v.finding_id = f.id;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Scanner.Triage</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0-*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-*" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,228 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
namespace StellaOps.Scanner.Triage;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core DbContext for the Triage schema.
|
||||
/// </summary>
|
||||
public sealed class TriageDbContext : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TriageDbContext"/> class.
|
||||
/// </summary>
|
||||
public TriageDbContext(DbContextOptions<TriageDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triage findings (cases).
|
||||
/// </summary>
|
||||
public DbSet<TriageFinding> Findings => Set<TriageFinding>();
|
||||
|
||||
/// <summary>
|
||||
/// Effective VEX records.
|
||||
/// </summary>
|
||||
public DbSet<TriageEffectiveVex> EffectiveVex => Set<TriageEffectiveVex>();
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis results.
|
||||
/// </summary>
|
||||
public DbSet<TriageReachabilityResult> ReachabilityResults => Set<TriageReachabilityResult>();
|
||||
|
||||
/// <summary>
|
||||
/// Risk/lattice evaluation results.
|
||||
/// </summary>
|
||||
public DbSet<TriageRiskResult> RiskResults => Set<TriageRiskResult>();
|
||||
|
||||
/// <summary>
|
||||
/// Triage decisions.
|
||||
/// </summary>
|
||||
public DbSet<TriageDecision> Decisions => Set<TriageDecision>();
|
||||
|
||||
/// <summary>
|
||||
/// Evidence artifacts.
|
||||
/// </summary>
|
||||
public DbSet<TriageEvidenceArtifact> EvidenceArtifacts => Set<TriageEvidenceArtifact>();
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots for Smart-Diff.
|
||||
/// </summary>
|
||||
public DbSet<TriageSnapshot> Snapshots => Set<TriageSnapshot>();
|
||||
|
||||
/// <summary>
|
||||
/// Current case view (read-only).
|
||||
/// </summary>
|
||||
public DbSet<TriageCaseCurrent> CurrentCases => Set<TriageCaseCurrent>();
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configure PostgreSQL enums
|
||||
modelBuilder.HasPostgresEnum<TriageLane>("triage_lane");
|
||||
modelBuilder.HasPostgresEnum<TriageVerdict>("triage_verdict");
|
||||
modelBuilder.HasPostgresEnum<TriageReachability>("triage_reachability");
|
||||
modelBuilder.HasPostgresEnum<TriageVexStatus>("triage_vex_status");
|
||||
modelBuilder.HasPostgresEnum<TriageDecisionKind>("triage_decision_kind");
|
||||
modelBuilder.HasPostgresEnum<TriageSnapshotTrigger>("triage_snapshot_trigger");
|
||||
modelBuilder.HasPostgresEnum<TriageEvidenceType>("triage_evidence_type");
|
||||
|
||||
// Configure TriageFinding
|
||||
modelBuilder.Entity<TriageFinding>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_finding");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => e.LastSeenAt)
|
||||
.IsDescending()
|
||||
.HasDatabaseName("ix_triage_finding_last_seen");
|
||||
|
||||
entity.HasIndex(e => e.AssetLabel)
|
||||
.HasDatabaseName("ix_triage_finding_asset_label");
|
||||
|
||||
entity.HasIndex(e => e.Purl)
|
||||
.HasDatabaseName("ix_triage_finding_purl");
|
||||
|
||||
entity.HasIndex(e => e.CveId)
|
||||
.HasDatabaseName("ix_triage_finding_cve");
|
||||
|
||||
entity.HasIndex(e => new { e.AssetId, e.EnvironmentId, e.Purl, e.CveId, e.RuleId })
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
// Configure TriageEffectiveVex
|
||||
modelBuilder.Entity<TriageEffectiveVex>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_effective_vex");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.CollectedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_effective_vex_finding");
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.EffectiveVexRecords)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageReachabilityResult
|
||||
modelBuilder.Entity<TriageReachabilityResult>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_reachability_result");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.ComputedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_reachability_finding");
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.ReachabilityResults)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageRiskResult
|
||||
modelBuilder.Entity<TriageRiskResult>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_risk_result");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.ComputedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_risk_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.Lane, e.ComputedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_risk_lane");
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.PolicyId, e.PolicyVersion, e.InputsHash })
|
||||
.IsUnique();
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.RiskResults)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageDecision
|
||||
modelBuilder.Entity<TriageDecision>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_decision");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_decision_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.Kind, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_decision_kind");
|
||||
|
||||
entity.HasIndex(e => e.FindingId)
|
||||
.HasFilter("revoked_at IS NULL")
|
||||
.HasDatabaseName("ix_triage_decision_active");
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.Decisions)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageEvidenceArtifact
|
||||
modelBuilder.Entity<TriageEvidenceArtifact>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_evidence_artifact");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_evidence_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.Type, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_evidence_type");
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.Type, e.ContentHash })
|
||||
.IsUnique();
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.EvidenceArtifacts)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageSnapshot
|
||||
modelBuilder.Entity<TriageSnapshot>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_snapshot");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_snapshot_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.Trigger, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_snapshot_trigger");
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.ToInputsHash, e.CreatedAt })
|
||||
.IsUnique();
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.Snapshots)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure the read-only view
|
||||
modelBuilder.Entity<TriageCaseCurrent>(entity =>
|
||||
{
|
||||
entity.ToView("v_triage_case_current");
|
||||
entity.HasNoKey();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user