new advisories work and features gaps work

This commit is contained in:
master
2026-01-14 18:39:19 +02:00
parent 95d5898650
commit 15aeac8e8b
148 changed files with 16731 additions and 554 deletions

View File

@@ -41,6 +41,11 @@ public sealed record UnifiedEvidenceResponseDto
/// <summary>Policy evaluation evidence.</summary>
public PolicyEvidenceDto? Policy { get; init; }
// Sprint: SPRINT_20260112_009_SCANNER_binary_diff_bundle_export (BINDIFF-SCAN-001)
/// <summary>Binary diff evidence with semantic and structural changes.</summary>
public BinaryDiffEvidenceDto? BinaryDiff { get; init; }
// === Manifest Hashes ===
/// <summary>Content-addressed hashes for determinism verification.</summary>
@@ -388,3 +393,131 @@ public sealed record VerificationStatusDto
/// <summary>Last verification timestamp.</summary>
public DateTimeOffset? VerifiedAt { get; init; }
}
// Sprint: SPRINT_20260112_009_SCANNER_binary_diff_bundle_export (BINDIFF-SCAN-001)
/// <summary>
/// Binary diff evidence for unified evidence response.
/// </summary>
public sealed record BinaryDiffEvidenceDto
{
/// <summary>Evidence status.</summary>
public required string Status { get; init; }
/// <summary>SHA-256 hash of the evidence content.</summary>
public string? Hash { get; init; }
/// <summary>Previous binary artifact digest.</summary>
public string? PreviousBinaryDigest { get; init; }
/// <summary>Current binary artifact digest.</summary>
public string? CurrentBinaryDigest { get; init; }
/// <summary>Type of diff (structural, semantic, hybrid).</summary>
public string? DiffType { get; init; }
/// <summary>Binary format/ISA (e.g., elf-x86_64).</summary>
public string? BinaryFormat { get; init; }
/// <summary>Tool and version used for diffing.</summary>
public string? ToolVersion { get; init; }
/// <summary>Overall similarity score (0.0-1.0).</summary>
public double? SimilarityScore { get; init; }
/// <summary>Number of function-level changes.</summary>
public int FunctionChangeCount { get; init; }
/// <summary>Number of symbol-level changes.</summary>
public int SymbolChangeCount { get; init; }
/// <summary>Number of section-level changes.</summary>
public int SectionChangeCount { get; init; }
/// <summary>Number of security-relevant changes.</summary>
public int SecurityChangeCount { get; init; }
/// <summary>Whether semantic diff is available.</summary>
public bool HasSemanticDiff { get; init; }
/// <summary>Semantic similarity score (0.0-1.0).</summary>
public double? SemanticSimilarity { get; init; }
/// <summary>Function-level changes.</summary>
public IReadOnlyList<BinaryFunctionDiffDto>? FunctionChanges { get; init; }
/// <summary>Security-relevant changes.</summary>
public IReadOnlyList<BinarySecurityChangeDto>? SecurityChanges { get; init; }
/// <summary>DSSE attestation reference for binary diff.</summary>
public AttestationRefDto? Attestation { get; init; }
/// <summary>CAS URI for full binary diff evidence.</summary>
public string? CasUri { get; init; }
}
/// <summary>
/// Function-level diff entry for binary diff.
/// </summary>
public sealed record BinaryFunctionDiffDto
{
/// <summary>Diff operation (added, removed, modified).</summary>
public required string Operation { get; init; }
/// <summary>Function name.</summary>
public required string FunctionName { get; init; }
/// <summary>Function signature (if available).</summary>
public string? Signature { get; init; }
/// <summary>Semantic similarity score for modified functions.</summary>
public double? Similarity { get; init; }
/// <summary>Node hash for reachability correlation.</summary>
public string? NodeHash { get; init; }
/// <summary>Whether this function is security-sensitive.</summary>
public bool SecuritySensitive { get; init; }
}
/// <summary>
/// Security-relevant change in binary.
/// </summary>
public sealed record BinarySecurityChangeDto
{
/// <summary>Type of security change.</summary>
public required string ChangeType { get; init; }
/// <summary>Severity level (info, warning, critical).</summary>
public required string Severity { get; init; }
/// <summary>Description of the change.</summary>
public required string Description { get; init; }
/// <summary>Affected function name.</summary>
public string? AffectedFunction { get; init; }
/// <summary>Suggested remediation.</summary>
public string? Remediation { get; init; }
}
/// <summary>
/// Attestation reference for evidence.
/// </summary>
public sealed record AttestationRefDto
{
/// <summary>Attestation ID.</summary>
public required string Id { get; init; }
/// <summary>Predicate type URI.</summary>
public required string PredicateType { get; init; }
/// <summary>DSSE envelope digest.</summary>
public string? EnvelopeDigest { get; init; }
/// <summary>Rekor log index (if anchored).</summary>
public long? RekorLogIndex { get; init; }
/// <summary>CAS URI for full attestation.</summary>
public string? CasUri { get; init; }
}

View File

@@ -0,0 +1,378 @@
// <copyright file="EpssChangeEvent.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_005_SCANNER_epss_reanalysis_events (SCAN-EPSS-001, SCAN-EPSS-003)
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Core.Epss;
/// <summary>
/// Event emitted when EPSS scores change significantly, triggering policy reanalysis.
/// </summary>
public sealed record EpssChangeEvent
{
/// <summary>
/// Unique event identifier (deterministic based on CVE and model date).
/// </summary>
[JsonPropertyName("eventId")]
public required string EventId { get; init; }
/// <summary>
/// Event type constant.
/// </summary>
[JsonPropertyName("eventType")]
public string EventType { get; init; } = EpssEventTypes.Updated;
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant")]
public required string Tenant { get; init; }
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cveId")]
public required string CveId { get; init; }
/// <summary>
/// Previous EPSS score (null for new entries).
/// </summary>
[JsonPropertyName("previousScore")]
public double? PreviousScore { get; init; }
/// <summary>
/// New EPSS score.
/// </summary>
[JsonPropertyName("newScore")]
public required double NewScore { get; init; }
/// <summary>
/// Score delta (absolute change).
/// </summary>
[JsonPropertyName("scoreDelta")]
public required double ScoreDelta { get; init; }
/// <summary>
/// Previous percentile (null for new entries).
/// </summary>
[JsonPropertyName("previousPercentile")]
public double? PreviousPercentile { get; init; }
/// <summary>
/// New percentile.
/// </summary>
[JsonPropertyName("newPercentile")]
public required double NewPercentile { get; init; }
/// <summary>
/// Percentile delta (absolute change).
/// </summary>
[JsonPropertyName("percentileDelta")]
public required double PercentileDelta { get; init; }
/// <summary>
/// Previous priority band (null for new entries).
/// </summary>
[JsonPropertyName("previousBand")]
public string? PreviousBand { get; init; }
/// <summary>
/// New priority band.
/// </summary>
[JsonPropertyName("newBand")]
public required string NewBand { get; init; }
/// <summary>
/// Whether the priority band changed.
/// </summary>
[JsonPropertyName("bandChanged")]
public bool BandChanged { get; init; }
/// <summary>
/// EPSS model date for the new score.
/// </summary>
[JsonPropertyName("modelDate")]
public required string ModelDate { get; init; }
/// <summary>
/// Previous model date (null for new entries).
/// </summary>
[JsonPropertyName("previousModelDate")]
public string? PreviousModelDate { get; init; }
/// <summary>
/// Whether this change exceeds the reanalysis threshold.
/// </summary>
[JsonPropertyName("exceedsThreshold")]
public required bool ExceedsThreshold { get; init; }
/// <summary>
/// Threshold that was exceeded (e.g., 0.2 for score delta).
/// </summary>
[JsonPropertyName("thresholdExceeded")]
public double? ThresholdExceeded { get; init; }
/// <summary>
/// Source of the EPSS data.
/// </summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
/// <summary>
/// UTC timestamp when this event was created.
/// </summary>
[JsonPropertyName("createdAtUtc")]
public required DateTimeOffset CreatedAtUtc { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("traceId")]
public string? TraceId { get; init; }
}
/// <summary>
/// Batch of EPSS change events for bulk processing.
/// </summary>
public sealed record EpssChangeBatch
{
/// <summary>
/// Unique batch identifier.
/// </summary>
[JsonPropertyName("batchId")]
public required string BatchId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant")]
public required string Tenant { get; init; }
/// <summary>
/// Model date for all changes in this batch.
/// </summary>
[JsonPropertyName("modelDate")]
public required string ModelDate { get; init; }
/// <summary>
/// Total number of CVEs processed.
/// </summary>
[JsonPropertyName("totalProcessed")]
public required int TotalProcessed { get; init; }
/// <summary>
/// Number of CVEs with changes exceeding threshold.
/// </summary>
[JsonPropertyName("changesExceedingThreshold")]
public required int ChangesExceedingThreshold { get; init; }
/// <summary>
/// Individual change events (only those exceeding threshold).
/// </summary>
[JsonPropertyName("changes")]
public required ImmutableArray<EpssChangeEvent> Changes { get; init; }
/// <summary>
/// UTC timestamp when this batch was created.
/// </summary>
[JsonPropertyName("createdAtUtc")]
public required DateTimeOffset CreatedAtUtc { get; init; }
}
/// <summary>
/// Well-known EPSS event types.
/// </summary>
public static class EpssEventTypes
{
/// <summary>
/// EPSS score updated for a CVE.
/// </summary>
public const string Updated = "epss.updated";
/// <summary>
/// Versioned event type alias for routing.
/// </summary>
public const string UpdatedV1 = "epss.updated@1";
/// <summary>
/// EPSS delta exceeded threshold (triggers reanalysis).
/// </summary>
public const string DeltaExceeded = "epss.delta.exceeded";
/// <summary>
/// New CVE added to EPSS data.
/// </summary>
public const string NewCve = "epss.cve.new";
/// <summary>
/// Batch processing completed.
/// </summary>
public const string BatchCompleted = "epss.batch.completed";
}
/// <summary>
/// EPSS change thresholds for reanalysis triggers.
/// </summary>
public static class EpssThresholds
{
/// <summary>
/// Default score delta threshold for reanalysis (0.2 = 20% probability change).
/// </summary>
public const double DefaultScoreDelta = 0.2;
/// <summary>
/// Default percentile delta threshold for reanalysis (0.1 = 10 percentile points).
/// </summary>
public const double DefaultPercentileDelta = 0.1;
/// <summary>
/// High priority score threshold (above this triggers immediate reanalysis).
/// </summary>
public const double HighPriorityScore = 0.7;
}
/// <summary>
/// Factory for creating deterministic EPSS change events.
/// </summary>
public static class EpssChangeEventFactory
{
/// <summary>
/// Creates an EPSS change event with deterministic event ID.
/// </summary>
public static EpssChangeEvent Create(
string tenant,
string cveId,
EpssEvidence? previous,
EpssEvidence current,
DateTimeOffset createdAtUtc,
double scoreDeltaThreshold = EpssThresholds.DefaultScoreDelta,
string? traceId = null)
{
var scoreDelta = previous is not null
? Math.Abs(current.Score - previous.Score)
: current.Score;
var percentileDelta = previous is not null
? Math.Abs(current.Percentile - previous.Percentile)
: current.Percentile;
var newBand = ComputePriorityBand(current.Score, current.Percentile);
var previousBand = previous is not null
? ComputePriorityBand(previous.Score, previous.Percentile)
: null;
var bandChanged = previousBand is not null && !string.Equals(previousBand, newBand, StringComparison.Ordinal);
var exceedsThreshold = scoreDelta >= scoreDeltaThreshold
|| current.Score >= EpssThresholds.HighPriorityScore
|| bandChanged;
var eventType = previous is null
? EpssEventTypes.NewCve
: exceedsThreshold
? EpssEventTypes.DeltaExceeded
: EpssEventTypes.Updated;
var eventId = ComputeEventId(
cveId,
current.ModelDate,
current.Score);
return new EpssChangeEvent
{
EventId = eventId,
EventType = eventType,
Tenant = tenant,
CveId = cveId,
PreviousScore = previous?.Score,
NewScore = current.Score,
ScoreDelta = scoreDelta,
PreviousPercentile = previous?.Percentile,
NewPercentile = current.Percentile,
PercentileDelta = percentileDelta,
PreviousBand = previousBand,
NewBand = newBand,
BandChanged = bandChanged,
ModelDate = current.ModelDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
PreviousModelDate = previous?.ModelDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
ExceedsThreshold = exceedsThreshold,
ThresholdExceeded = exceedsThreshold ? scoreDeltaThreshold : null,
Source = current.Source,
CreatedAtUtc = createdAtUtc,
TraceId = traceId
};
}
/// <summary>
/// Creates a batch of EPSS change events.
/// </summary>
public static EpssChangeBatch CreateBatch(
string tenant,
DateOnly modelDate,
IEnumerable<EpssChangeEvent> allChanges,
DateTimeOffset createdAtUtc)
{
var changesList = allChanges.ToList();
var thresholdChanges = changesList
.Where(c => c.ExceedsThreshold)
.OrderBy(c => c.CveId, StringComparer.Ordinal)
.ToImmutableArray();
var batchId = ComputeBatchId(tenant, modelDate, thresholdChanges.Length);
return new EpssChangeBatch
{
BatchId = batchId,
Tenant = tenant,
ModelDate = modelDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
TotalProcessed = changesList.Count,
ChangesExceedingThreshold = thresholdChanges.Length,
Changes = thresholdChanges,
CreatedAtUtc = createdAtUtc
};
}
private static string ComputeEventId(string cveId, DateOnly modelDate, double score)
{
var input = $"{cveId}|{modelDate:yyyy-MM-dd}|{score:F6}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"epss-evt-{Convert.ToHexStringLower(hash)[..16]}";
}
private static string ComputeBatchId(string tenant, DateOnly modelDate, int changeCount)
{
var input = $"{tenant}|{modelDate:yyyy-MM-dd}|{changeCount}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"epss-batch-{Convert.ToHexStringLower(hash)[..16]}";
}
private static string ComputePriorityBand(double score, double percentile)
{
// Critical: Top 1% by percentile or score > 0.8
if (percentile >= 0.99 || score > 0.8)
{
return "critical";
}
// High: Top 5% by percentile or score > 0.5
if (percentile >= 0.95 || score > 0.5)
{
return "high";
}
// Medium: Top 25% by percentile
if (percentile >= 0.75)
{
return "medium";
}
// Low: Below top 25%
return "low";
}
}

View File

@@ -28,6 +28,8 @@ namespace StellaOps.Scanner.Core;
/// <param name="Deterministic">Whether the scan was run in deterministic mode.</param>
/// <param name="Seed">32-byte seed for deterministic replay.</param>
/// <param name="Knobs">Configuration knobs affecting the scan (depth limits, etc.).</param>
/// <param name="ToolVersions">Version information for all tools used in the scan pipeline.</param>
/// <param name="EvidenceDigests">Content-addressed digests of evidence artifacts for policy fingerprinting.</param>
public sealed record ScanManifest(
[property: JsonPropertyName("scanId")] string ScanId,
[property: JsonPropertyName("createdAtUtc")] DateTimeOffset CreatedAtUtc,
@@ -40,7 +42,10 @@ public sealed record ScanManifest(
[property: JsonPropertyName("latticePolicyHash")] string LatticePolicyHash,
[property: JsonPropertyName("deterministic")] bool Deterministic,
[property: JsonPropertyName("seed")] byte[] Seed,
[property: JsonPropertyName("knobs")] IReadOnlyDictionary<string, string> Knobs)
[property: JsonPropertyName("knobs")] IReadOnlyDictionary<string, string> Knobs,
// Sprint: SPRINT_20260112_005_SCANNER_epss_reanalysis_events (SCAN-EPSS-002)
[property: JsonPropertyName("toolVersions")] ScanToolVersions? ToolVersions = null,
[property: JsonPropertyName("evidenceDigests")] ScanEvidenceDigests? EvidenceDigests = null)
{
/// <summary>
/// Default JSON serializer options for canonical output.
@@ -92,6 +97,90 @@ public sealed record ScanManifest(
}
}
// Sprint: SPRINT_20260112_005_SCANNER_epss_reanalysis_events (SCAN-EPSS-002)
/// <summary>
/// Version information for all tools used in the scan pipeline.
/// Used for policy fingerprinting and offline replay validation.
/// </summary>
public sealed record ScanToolVersions
{
/// <summary>Scanner core version.</summary>
[JsonPropertyName("scannerCore")]
public string? ScannerCore { get; init; }
/// <summary>SBOM generator version (e.g., Syft).</summary>
[JsonPropertyName("sbomGenerator")]
public string? SbomGenerator { get; init; }
/// <summary>Vulnerability matcher version (e.g., Grype).</summary>
[JsonPropertyName("vulnerabilityMatcher")]
public string? VulnerabilityMatcher { get; init; }
/// <summary>Reachability analyzer version.</summary>
[JsonPropertyName("reachabilityAnalyzer")]
public string? ReachabilityAnalyzer { get; init; }
/// <summary>Binary indexer version.</summary>
[JsonPropertyName("binaryIndexer")]
public string? BinaryIndexer { get; init; }
/// <summary>EPSS model version (e.g., "v2024.01.15").</summary>
[JsonPropertyName("epssModel")]
public string? EpssModel { get; init; }
/// <summary>VEX evaluator version.</summary>
[JsonPropertyName("vexEvaluator")]
public string? VexEvaluator { get; init; }
/// <summary>Policy engine version.</summary>
[JsonPropertyName("policyEngine")]
public string? PolicyEngine { get; init; }
/// <summary>Additional tool versions as key-value pairs.</summary>
[JsonPropertyName("additional")]
public IReadOnlyDictionary<string, string>? Additional { get; init; }
}
/// <summary>
/// Content-addressed digests of evidence artifacts for policy fingerprinting.
/// Used to detect when reanalysis is required due to evidence changes.
/// </summary>
public sealed record ScanEvidenceDigests
{
/// <summary>Digest of the SBOM artifact.</summary>
[JsonPropertyName("sbomDigest")]
public string? SbomDigest { get; init; }
/// <summary>Digest of the vulnerability findings.</summary>
[JsonPropertyName("findingsDigest")]
public string? FindingsDigest { get; init; }
/// <summary>Digest of the reachability graph.</summary>
[JsonPropertyName("reachabilityDigest")]
public string? ReachabilityDigest { get; init; }
/// <summary>Digest of aggregated VEX claims.</summary>
[JsonPropertyName("vexDigest")]
public string? VexDigest { get; init; }
/// <summary>Digest of runtime signals.</summary>
[JsonPropertyName("runtimeDigest")]
public string? RuntimeDigest { get; init; }
/// <summary>Digest of binary diff evidence.</summary>
[JsonPropertyName("binaryDiffDigest")]
public string? BinaryDiffDigest { get; init; }
/// <summary>Digest of EPSS scores used.</summary>
[JsonPropertyName("epssDigest")]
public string? EpssDigest { get; init; }
/// <summary>Combined fingerprint of all evidence (for quick comparison).</summary>
[JsonPropertyName("combinedFingerprint")]
public string? CombinedFingerprint { get; init; }
}
/// <summary>
/// Builder for creating ScanManifest instances.
/// </summary>
@@ -110,6 +199,9 @@ public sealed class ScanManifestBuilder
private bool _deterministic = true;
private byte[] _seed = new byte[32];
private readonly Dictionary<string, string> _knobs = [];
// Sprint: SPRINT_20260112_005_SCANNER_epss_reanalysis_events (SCAN-EPSS-002)
private ScanToolVersions? _toolVersions;
private ScanEvidenceDigests? _evidenceDigests;
internal ScanManifestBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null)
{
@@ -187,6 +279,26 @@ public sealed class ScanManifestBuilder
return this;
}
/// <summary>
/// Sets the tool versions for policy fingerprinting.
/// Sprint: SPRINT_20260112_005_SCANNER_epss_reanalysis_events (SCAN-EPSS-002)
/// </summary>
public ScanManifestBuilder WithToolVersions(ScanToolVersions toolVersions)
{
_toolVersions = toolVersions;
return this;
}
/// <summary>
/// Sets the evidence digests for policy fingerprinting.
/// Sprint: SPRINT_20260112_005_SCANNER_epss_reanalysis_events (SCAN-EPSS-002)
/// </summary>
public ScanManifestBuilder WithEvidenceDigests(ScanEvidenceDigests evidenceDigests)
{
_evidenceDigests = evidenceDigests;
return this;
}
public ScanManifest Build() => new(
ScanId: _scanId,
CreatedAtUtc: _createdAtUtc ?? _timeProvider.GetUtcNow(),
@@ -199,5 +311,7 @@ public sealed class ScanManifestBuilder
LatticePolicyHash: _latticePolicyHash,
Deterministic: _deterministic,
Seed: _seed,
Knobs: _knobs.AsReadOnly());
Knobs: _knobs.AsReadOnly(),
ToolVersions: _toolVersions,
EvidenceDigests: _evidenceDigests);
}

View File

@@ -55,7 +55,9 @@ public sealed record RichGraphNode(
IReadOnlyDictionary<string, string>? Attributes,
string? SymbolDigest,
ReachabilitySymbol? Symbol = null,
string? CodeBlockHash = null)
string? CodeBlockHash = null,
// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-002)
string? NodeHash = null)
{
public RichGraphNode Trimmed()
{
@@ -71,6 +73,7 @@ public sealed record RichGraphNode(
BuildId = string.IsNullOrWhiteSpace(BuildId) ? null : BuildId.Trim(),
CodeBlockHash = string.IsNullOrWhiteSpace(CodeBlockHash) ? null : CodeBlockHash.Trim(),
SymbolDigest = string.IsNullOrWhiteSpace(SymbolDigest) ? null : SymbolDigest.Trim(),
NodeHash = string.IsNullOrWhiteSpace(NodeHash) ? null : NodeHash.Trim(),
Symbol = Symbol?.Trimmed(),
Evidence = Evidence is null
? Array.Empty<string>()

View File

@@ -53,6 +53,14 @@ public sealed record ReachabilitySubgraphNode
[JsonPropertyName("attributes")]
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-002)
/// <summary>
/// Canonical node hash computed from PURL and symbol using NodeHashRecipe.
/// </summary>
[JsonPropertyName("nodeHash")]
public string? NodeHash { get; init; }
}
/// <summary>