sprints and audit work
This commit is contained in:
@@ -63,7 +63,7 @@ public sealed record BoundaryExtractionContext
|
||||
public string? NetworkZone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Known port bindings (port → protocol).
|
||||
/// Known port bindings (port to protocol).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<int, string> PortBindings { get; init; } =
|
||||
new Dictionary<int, string>();
|
||||
|
||||
@@ -86,22 +86,22 @@ public sealed record GraphDelta
|
||||
AddedEdges.Count > 0 || RemovedEdges.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Nodes added in current graph (ΔV+).
|
||||
/// Nodes added in current graph (delta V+).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AddedNodes { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Nodes removed from previous graph (ΔV-).
|
||||
/// Nodes removed from previous graph (delta V-).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> RemovedNodes { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Edges added in current graph (ΔE+).
|
||||
/// Edges added in current graph (delta E+).
|
||||
/// </summary>
|
||||
public IReadOnlyList<GraphEdge> AddedEdges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Edges removed from previous graph (ΔE-).
|
||||
/// Edges removed from previous graph (delta E-).
|
||||
/// </summary>
|
||||
public IReadOnlyList<GraphEdge> RemovedEdges { get; init; } = [];
|
||||
|
||||
|
||||
@@ -396,7 +396,7 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
|
||||
{
|
||||
Level = PrAnnotationLevel.Error,
|
||||
Title = "New Reachable Vulnerability Path",
|
||||
Message = $"Vulnerability path became reachable: {flip.EntryMethodKey} → {flip.SinkMethodKey}",
|
||||
Message = $"Vulnerability path became reachable: {flip.EntryMethodKey} -> {flip.SinkMethodKey}",
|
||||
FilePath = flip.SourceFile,
|
||||
StartLine = flip.StartLine,
|
||||
EndLine = flip.EndLine
|
||||
@@ -440,7 +440,7 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
|
||||
|
||||
foreach (var flip in decision.BlockingFlips.Take(10))
|
||||
{
|
||||
sb.AppendLine($"- `{flip.EntryMethodKey}` → `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
|
||||
sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
|
||||
}
|
||||
|
||||
if (decision.BlockingFlips.Count > 10)
|
||||
|
||||
@@ -110,7 +110,7 @@ public sealed class PathRenderer : IPathRenderer
|
||||
// Hops
|
||||
foreach (var hop in path.Hops)
|
||||
{
|
||||
var prefix = hop.IsEntrypoint ? " " : " → ";
|
||||
var prefix = hop.IsEntrypoint ? " " : " -> ";
|
||||
var location = hop.File is not null && hop.Line.HasValue
|
||||
? $" ({hop.File}:{hop.Line})"
|
||||
: "";
|
||||
@@ -192,7 +192,7 @@ public sealed class PathRenderer : IPathRenderer
|
||||
sb.AppendLine("```");
|
||||
foreach (var hop in path.Hops)
|
||||
{
|
||||
var arrow = hop.IsEntrypoint ? "" : "→ ";
|
||||
var arrow = hop.IsEntrypoint ? "" : "-> ";
|
||||
var location = hop.File is not null && hop.Line.HasValue
|
||||
? $" ({hop.File}:{hop.Line})"
|
||||
: "";
|
||||
|
||||
@@ -131,7 +131,7 @@ public sealed class ReachabilityRichGraphPublisher : IRichGraphPublisher
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the hex digest from a prefixed hash (e.g., "blake3:abc123" → "abc123").
|
||||
/// Extracts the hex digest from a prefixed hash (e.g., "blake3:abc123" becomes "abc123").
|
||||
/// </summary>
|
||||
private static string ExtractHashDigest(string prefixedHash)
|
||||
{
|
||||
|
||||
@@ -72,24 +72,24 @@ public sealed class SliceDiffComputer
|
||||
}
|
||||
|
||||
private static string EdgeKey(SliceEdge edge)
|
||||
=> $"{edge.From}→{edge.To}:{edge.Kind}";
|
||||
=> $"{edge.From}->{edge.To}:{edge.Kind}";
|
||||
|
||||
private static string? ComputeVerdictDiff(SliceVerdict original, SliceVerdict recomputed)
|
||||
{
|
||||
if (original.Status != recomputed.Status)
|
||||
{
|
||||
return $"Status changed: {original.Status} → {recomputed.Status}";
|
||||
return $"Status changed: {original.Status} -> {recomputed.Status}";
|
||||
}
|
||||
|
||||
var confidenceDiff = Math.Abs(original.Confidence - recomputed.Confidence);
|
||||
if (confidenceDiff > 0.01)
|
||||
{
|
||||
return $"Confidence changed: {original.Confidence:F3} → {recomputed.Confidence:F3} (Δ={confidenceDiff:F3})";
|
||||
return $"Confidence changed: {original.Confidence:F3} -> {recomputed.Confidence:F3} (delta={confidenceDiff:F3})";
|
||||
}
|
||||
|
||||
if (original.UnknownCount != recomputed.UnknownCount)
|
||||
{
|
||||
return $"Unknown count changed: {original.UnknownCount} → {recomputed.UnknownCount}";
|
||||
return $"Unknown count changed: {original.UnknownCount} -> {recomputed.UnknownCount}";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IReachabilityResultFactory.cs
|
||||
// Sprint: SPRINT_20260106_001_002_SCANNER_suppression_proofs
|
||||
// Task: SUP-018
|
||||
// Description: Factory for creating ReachabilityResult with witnesses from
|
||||
// ReachabilityStack evaluations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Stack;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating <see cref="Witnesses.ReachabilityResult"/> from
|
||||
/// <see cref="ReachabilityStack"/> evaluations, including witness generation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This factory bridges the three-layer stack evaluation with the witness system:
|
||||
/// - For Unreachable verdicts: Creates SuppressionWitness explaining why
|
||||
/// - For Exploitable verdicts: Creates PathWitness documenting the reachable path
|
||||
/// - For Unknown verdicts: Returns result without witness
|
||||
/// </remarks>
|
||||
public interface IReachabilityResultFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Witnesses.ReachabilityResult"/> from a reachability stack,
|
||||
/// generating the appropriate witness based on the verdict.
|
||||
/// </summary>
|
||||
/// <param name="stack">The evaluated reachability stack.</param>
|
||||
/// <param name="context">Context for witness generation (SBOM, component info).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>ReachabilityResult with PathWitness or SuppressionWitness as appropriate.</returns>
|
||||
Task<Witnesses.ReachabilityResult> CreateResultAsync(
|
||||
ReachabilityStack stack,
|
||||
WitnessGenerationContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Witnesses.ReachabilityResult"/> for unknown/inconclusive analysis.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason why analysis was inconclusive.</param>
|
||||
/// <returns>ReachabilityResult with Unknown verdict.</returns>
|
||||
Witnesses.ReachabilityResult CreateUnknownResult(string reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for generating witnesses from reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record WitnessGenerationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM digest for artifact identification.
|
||||
/// </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", "OSV").
|
||||
/// </summary>
|
||||
public required string VulnSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range.
|
||||
/// </summary>
|
||||
public required string AffectedRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest (for container scans).
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call graph digest for reproducibility.
|
||||
/// </summary>
|
||||
public string? GraphDigest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachabilityResultFactory.cs
|
||||
// Sprint: SPRINT_20260106_001_002_SCANNER_suppression_proofs
|
||||
// Task: SUP-018
|
||||
// Description: Implementation of IReachabilityResultFactory that integrates
|
||||
// SuppressionWitnessBuilder with ReachabilityStack evaluation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Stack;
|
||||
|
||||
/// <summary>
|
||||
/// Factory that creates <see cref="Witnesses.ReachabilityResult"/> from
|
||||
/// <see cref="ReachabilityStack"/> evaluations by generating appropriate witnesses.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityResultFactory : IReachabilityResultFactory
|
||||
{
|
||||
private readonly ISuppressionWitnessBuilder _suppressionBuilder;
|
||||
private readonly ILogger<ReachabilityResultFactory> _logger;
|
||||
|
||||
public ReachabilityResultFactory(
|
||||
ISuppressionWitnessBuilder suppressionBuilder,
|
||||
ILogger<ReachabilityResultFactory> logger)
|
||||
{
|
||||
_suppressionBuilder = suppressionBuilder ?? throw new ArgumentNullException(nameof(suppressionBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Witnesses.ReachabilityResult> CreateResultAsync(
|
||||
ReachabilityStack stack,
|
||||
WitnessGenerationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stack);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
return stack.Verdict switch
|
||||
{
|
||||
ReachabilityVerdict.Unreachable => await CreateNotAffectedResultAsync(stack, context, cancellationToken).ConfigureAwait(false),
|
||||
ReachabilityVerdict.Exploitable or
|
||||
ReachabilityVerdict.LikelyExploitable or
|
||||
ReachabilityVerdict.PossiblyExploitable => CreateAffectedPlaceholderResult(stack),
|
||||
ReachabilityVerdict.Unknown => CreateUnknownResult(stack.Explanation ?? "Reachability could not be determined"),
|
||||
_ => CreateUnknownResult($"Unexpected verdict: {stack.Verdict}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a complete result with a pre-built PathWitness for affected findings.
|
||||
/// Use this when the caller has already built the PathWitness via IPathWitnessBuilder.
|
||||
/// </summary>
|
||||
public Witnesses.ReachabilityResult CreateAffectedResult(PathWitness pathWitness)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pathWitness);
|
||||
return Witnesses.ReachabilityResult.Affected(pathWitness);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Witnesses.ReachabilityResult CreateUnknownResult(string reason)
|
||||
{
|
||||
_logger.LogDebug("Creating Unknown reachability result: {Reason}", reason);
|
||||
return Witnesses.ReachabilityResult.Unknown();
|
||||
}
|
||||
|
||||
private async Task<Witnesses.ReachabilityResult> CreateNotAffectedResultAsync(
|
||||
ReachabilityStack stack,
|
||||
WitnessGenerationContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Creating NotAffected result for {VulnId} on {Purl}",
|
||||
context.VulnId,
|
||||
context.ComponentPurl);
|
||||
|
||||
// Determine suppression type based on which layer blocked
|
||||
var suppressionWitness = await DetermineSuppressionWitnessAsync(
|
||||
stack,
|
||||
context,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Witnesses.ReachabilityResult.NotAffected(suppressionWitness);
|
||||
}
|
||||
|
||||
private async Task<SuppressionWitness> DetermineSuppressionWitnessAsync(
|
||||
ReachabilityStack stack,
|
||||
WitnessGenerationContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check L1 - Static unreachability
|
||||
if (!stack.StaticCallGraph.IsReachable && stack.StaticCallGraph.Confidence >= ConfidenceLevel.Medium)
|
||||
{
|
||||
var request = new UnreachabilityRequest
|
||||
{
|
||||
SbomDigest = context.SbomDigest,
|
||||
ComponentPurl = context.ComponentPurl,
|
||||
VulnId = context.VulnId,
|
||||
VulnSource = context.VulnSource,
|
||||
AffectedRange = context.AffectedRange,
|
||||
AnalyzedEntrypoints = stack.StaticCallGraph.ReachingEntrypoints.Length,
|
||||
UnreachableSymbol = stack.Symbol.Name,
|
||||
AnalysisMethod = stack.StaticCallGraph.AnalysisMethod ?? "static",
|
||||
GraphDigest = context.GraphDigest ?? "unknown",
|
||||
Confidence = MapConfidence(stack.StaticCallGraph.Confidence),
|
||||
Justification = "Static call graph analysis shows no path from entrypoints to vulnerable symbol"
|
||||
};
|
||||
|
||||
return await _suppressionBuilder.BuildUnreachableAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Check L2 - Binary resolution failure (function absent)
|
||||
if (!stack.BinaryResolution.IsResolved && stack.BinaryResolution.Confidence >= ConfidenceLevel.Medium)
|
||||
{
|
||||
var request = new FunctionAbsentRequest
|
||||
{
|
||||
SbomDigest = context.SbomDigest,
|
||||
ComponentPurl = context.ComponentPurl,
|
||||
VulnId = context.VulnId,
|
||||
VulnSource = context.VulnSource,
|
||||
AffectedRange = context.AffectedRange,
|
||||
FunctionName = stack.Symbol.Name,
|
||||
BinaryDigest = stack.BinaryResolution.Resolution?.ResolvedLibrary ?? "unknown",
|
||||
VerificationMethod = "binary-resolution",
|
||||
Confidence = MapConfidence(stack.BinaryResolution.Confidence),
|
||||
Justification = stack.BinaryResolution.Reason ?? "Vulnerable symbol not found in binary"
|
||||
};
|
||||
|
||||
return await _suppressionBuilder.BuildFunctionAbsentAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Check L3 - Runtime gating
|
||||
if (stack.RuntimeGating.IsGated &&
|
||||
stack.RuntimeGating.Outcome == GatingOutcome.Blocked &&
|
||||
stack.RuntimeGating.Confidence >= ConfidenceLevel.Medium)
|
||||
{
|
||||
var detectedGates = stack.RuntimeGating.Conditions
|
||||
.Where(c => c.IsBlocking)
|
||||
.Select(c => new Witnesses.DetectedGate
|
||||
{
|
||||
Type = MapGateType(c.Type.ToString()),
|
||||
GuardSymbol = c.ConfigKey ?? c.EnvVar ?? c.Description,
|
||||
Confidence = MapConditionConfidence(c)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var request = new GateBlockedRequest
|
||||
{
|
||||
SbomDigest = context.SbomDigest,
|
||||
ComponentPurl = context.ComponentPurl,
|
||||
VulnId = context.VulnId,
|
||||
VulnSource = context.VulnSource,
|
||||
AffectedRange = context.AffectedRange,
|
||||
DetectedGates = detectedGates,
|
||||
GateCoveragePercent = CalculateGateCoverage(stack.RuntimeGating),
|
||||
Effectiveness = "blocking",
|
||||
Confidence = MapConfidence(stack.RuntimeGating.Confidence),
|
||||
Justification = "Runtime gates block all exploitation paths"
|
||||
};
|
||||
|
||||
return await _suppressionBuilder.BuildGateBlockedAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Fallback: general unreachability
|
||||
_logger.LogWarning(
|
||||
"Could not determine specific suppression type for {VulnId}; using generic unreachability",
|
||||
context.VulnId);
|
||||
|
||||
var fallbackRequest = new UnreachabilityRequest
|
||||
{
|
||||
SbomDigest = context.SbomDigest,
|
||||
ComponentPurl = context.ComponentPurl,
|
||||
VulnId = context.VulnId,
|
||||
VulnSource = context.VulnSource,
|
||||
AffectedRange = context.AffectedRange,
|
||||
AnalyzedEntrypoints = 0,
|
||||
UnreachableSymbol = stack.Symbol.Name,
|
||||
AnalysisMethod = "combined",
|
||||
GraphDigest = context.GraphDigest ?? "unknown",
|
||||
Confidence = 0.5,
|
||||
Justification = stack.Explanation ?? "Reachability analysis determined not affected"
|
||||
};
|
||||
|
||||
return await _suppressionBuilder.BuildUnreachableAsync(fallbackRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a placeholder Affected result when PathWitness is not yet available.
|
||||
/// The caller should use CreateAffectedResult(PathWitness) when they have built the witness.
|
||||
/// </summary>
|
||||
private Witnesses.ReachabilityResult CreateAffectedPlaceholderResult(ReachabilityStack stack)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Verdict is {Verdict} for finding {FindingId} - PathWitness should be built separately",
|
||||
stack.Verdict,
|
||||
stack.FindingId);
|
||||
|
||||
// Return Unknown with metadata indicating affected; caller should build PathWitness
|
||||
// and call CreateAffectedResult(pathWitness) to get proper result
|
||||
return Witnesses.ReachabilityResult.Unknown();
|
||||
}
|
||||
|
||||
private static double MapConfidence(ConfidenceLevel level) => level switch
|
||||
{
|
||||
ConfidenceLevel.High => 0.95,
|
||||
ConfidenceLevel.Medium => 0.75,
|
||||
ConfidenceLevel.Low => 0.50,
|
||||
_ => 0.50
|
||||
};
|
||||
|
||||
private static double MapVerdictConfidence(ReachabilityVerdict verdict) => verdict switch
|
||||
{
|
||||
ReachabilityVerdict.Exploitable => 0.95,
|
||||
ReachabilityVerdict.LikelyExploitable => 0.80,
|
||||
ReachabilityVerdict.PossiblyExploitable => 0.60,
|
||||
_ => 0.50
|
||||
};
|
||||
|
||||
private static string MapGateType(string conditionType) => conditionType switch
|
||||
{
|
||||
"authentication" => "auth",
|
||||
"authorization" => "authz",
|
||||
"validation" => "validation",
|
||||
"rate-limiting" => "rate-limit",
|
||||
"feature-flag" => "feature-flag",
|
||||
_ => conditionType
|
||||
};
|
||||
|
||||
private static double MapConditionConfidence(GatingCondition condition) =>
|
||||
condition.IsBlocking ? 0.90 : 0.60;
|
||||
|
||||
private static int CalculateGateCoverage(ReachabilityLayer3 layer3)
|
||||
{
|
||||
if (layer3.Conditions.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var blockingCount = layer3.Conditions.Count(c => c.IsBlocking);
|
||||
return (int)(100.0 * blockingCount / layer3.Conditions.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and verifying DSSE-signed suppression witness envelopes.
|
||||
/// Sprint: SPRINT_20260106_001_002 (SUP-014)
|
||||
/// </summary>
|
||||
public interface ISuppressionDsseSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a suppression witness and wraps it in a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="witness">The suppression witness to sign.</param>
|
||||
/// <param name="signingKey">The key to sign with.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the signed DSSE envelope.</returns>
|
||||
SuppressionDsseResult SignWitness(
|
||||
SuppressionWitness witness,
|
||||
EnvelopeKey signingKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE-signed suppression witness envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope to verify.</param>
|
||||
/// <param name="publicKey">The public key to verify with.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the verified witness.</returns>
|
||||
SuppressionVerifyResult VerifyWitness(
|
||||
DsseEnvelope envelope,
|
||||
EnvelopeKey publicKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Builds suppression witnesses from evidence that a vulnerability is not exploitable.
|
||||
/// </summary>
|
||||
public interface ISuppressionWitnessBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a suppression witness for unreachable vulnerable code.
|
||||
/// </summary>
|
||||
Task<SuppressionWitness> BuildUnreachableAsync(
|
||||
UnreachabilityRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a suppression witness for a patched symbol.
|
||||
/// </summary>
|
||||
Task<SuppressionWitness> BuildPatchedSymbolAsync(
|
||||
PatchedSymbolRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a suppression witness for absent function.
|
||||
/// </summary>
|
||||
Task<SuppressionWitness> BuildFunctionAbsentAsync(
|
||||
FunctionAbsentRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a suppression witness for gate-blocked exploitation.
|
||||
/// </summary>
|
||||
Task<SuppressionWitness> BuildGateBlockedAsync(
|
||||
GateBlockedRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a suppression witness for feature flag disabled code.
|
||||
/// </summary>
|
||||
Task<SuppressionWitness> BuildFeatureFlagDisabledAsync(
|
||||
FeatureFlagRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a suppression witness from a VEX statement.
|
||||
/// </summary>
|
||||
Task<SuppressionWitness> BuildFromVexStatementAsync(
|
||||
VexStatementRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a suppression witness for version not affected.
|
||||
/// </summary>
|
||||
Task<SuppressionWitness> BuildVersionNotAffectedAsync(
|
||||
VersionRangeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a suppression witness for linker garbage collected code.
|
||||
/// </summary>
|
||||
Task<SuppressionWitness> BuildLinkerGarbageCollectedAsync(
|
||||
LinkerGcRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common properties for all suppression witness requests.
|
||||
/// </summary>
|
||||
public abstract record BaseSuppressionRequest
|
||||
{
|
||||
/// <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>
|
||||
/// Optional justification narrative.
|
||||
/// </summary>
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiration for time-bounded suppressions.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build unreachability suppression witness.
|
||||
/// </summary>
|
||||
public sealed record UnreachabilityRequest : BaseSuppressionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of entrypoints analyzed.
|
||||
/// </summary>
|
||||
public required int AnalyzedEntrypoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable symbol confirmed unreachable.
|
||||
/// </summary>
|
||||
public required string UnreachableSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis method (static, dynamic, hybrid).
|
||||
/// </summary>
|
||||
public required string AnalysisMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph digest for reproducibility.
|
||||
/// </summary>
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build patched symbol suppression witness.
|
||||
/// </summary>
|
||||
public sealed record PatchedSymbolRequest : BaseSuppressionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerable symbol identifier.
|
||||
/// </summary>
|
||||
public required string VulnerableSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Patched symbol identifier.
|
||||
/// </summary>
|
||||
public required string PatchedSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol diff showing the patch.
|
||||
/// </summary>
|
||||
public required string SymbolDiff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Patch commit or release reference.
|
||||
/// </summary>
|
||||
public string? PatchRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build function absent suppression witness.
|
||||
/// </summary>
|
||||
public sealed record FunctionAbsentRequest : BaseSuppressionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerable function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary digest where function was checked.
|
||||
/// </summary>
|
||||
public required string BinaryDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification method (symbol table scan, disassembly, etc.).
|
||||
/// </summary>
|
||||
public required string VerificationMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build gate blocked suppression witness.
|
||||
/// </summary>
|
||||
public sealed record GateBlockedRequest : BaseSuppressionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Detected gates along all paths to vulnerable code.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DetectedGate> DetectedGates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum gate coverage percentage ([0, 100]).
|
||||
/// </summary>
|
||||
public required int GateCoveragePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate effectiveness assessment.
|
||||
/// </summary>
|
||||
public required string Effectiveness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build feature flag suppression witness.
|
||||
/// </summary>
|
||||
public sealed record FeatureFlagRequest : BaseSuppressionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Feature flag name.
|
||||
/// </summary>
|
||||
public required string FlagName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag state (enabled, disabled).
|
||||
/// </summary>
|
||||
public required string FlagState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag configuration source.
|
||||
/// </summary>
|
||||
public required string ConfigSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable code path guarded by flag.
|
||||
/// </summary>
|
||||
public string? GuardedPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build VEX statement suppression witness.
|
||||
/// </summary>
|
||||
public sealed record VexStatementRequest : BaseSuppressionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX document identifier.
|
||||
/// </summary>
|
||||
public required string VexId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document author/source.
|
||||
/// </summary>
|
||||
public required string VexAuthor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement status.
|
||||
/// </summary>
|
||||
public required string VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification from VEX statement.
|
||||
/// </summary>
|
||||
public string? VexJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document digest for verification.
|
||||
/// </summary>
|
||||
public string? VexDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build version range suppression witness.
|
||||
/// </summary>
|
||||
public sealed record VersionRangeRequest : BaseSuppressionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Installed version.
|
||||
/// </summary>
|
||||
public required string InstalledVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed version comparison result.
|
||||
/// </summary>
|
||||
public required string ComparisonResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version scheme (semver, rpm, deb, etc.).
|
||||
/// </summary>
|
||||
public required string VersionScheme { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build linker GC suppression witness.
|
||||
/// </summary>
|
||||
public sealed record LinkerGcRequest : BaseSuppressionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerable symbol that was collected.
|
||||
/// </summary>
|
||||
public required string CollectedSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linker log or report showing removal.
|
||||
/// </summary>
|
||||
public string? LinkerLog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linker used (ld, lld, link.exe, etc.).
|
||||
/// </summary>
|
||||
public required string Linker { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build flags that enabled GC.
|
||||
/// </summary>
|
||||
public required string BuildFlags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
@@ -402,7 +402,7 @@ public sealed class PathWitnessBuilder : IPathWitnessBuilder
|
||||
parent.TryGetValue(current, out current);
|
||||
}
|
||||
|
||||
path.Reverse(); // Reverse to get source → target order
|
||||
path.Reverse(); // Reverse to get source -> target order
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Unified result type for reachability analysis that contains either a PathWitness (affected)
|
||||
/// or a SuppressionWitness (not affected).
|
||||
/// Sprint: SPRINT_20260106_001_002 (SUP-017)
|
||||
/// </summary>
|
||||
public sealed record ReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The reachability verdict.
|
||||
/// </summary>
|
||||
public required ReachabilityVerdict Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Witness proving vulnerability is reachable (when Verdict = Affected).
|
||||
/// </summary>
|
||||
public PathWitness? PathWitness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Witness proving vulnerability is not exploitable (when Verdict = NotAffected).
|
||||
/// </summary>
|
||||
public SuppressionWitness? SuppressionWitness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating the vulnerability is affected/reachable.
|
||||
/// </summary>
|
||||
/// <param name="witness">PathWitness proving reachability.</param>
|
||||
/// <returns>ReachabilityResult with Affected verdict.</returns>
|
||||
public static ReachabilityResult Affected(PathWitness witness) =>
|
||||
new() { Verdict = ReachabilityVerdict.Affected, PathWitness = witness };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating the vulnerability is not affected/not exploitable.
|
||||
/// </summary>
|
||||
/// <param name="witness">SuppressionWitness explaining why not affected.</param>
|
||||
/// <returns>ReachabilityResult with NotAffected verdict.</returns>
|
||||
public static ReachabilityResult NotAffected(SuppressionWitness witness) =>
|
||||
new() { Verdict = ReachabilityVerdict.NotAffected, SuppressionWitness = witness };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating reachability could not be determined.
|
||||
/// </summary>
|
||||
/// <returns>ReachabilityResult with Unknown verdict.</returns>
|
||||
public static ReachabilityResult Unknown() =>
|
||||
new() { Verdict = ReachabilityVerdict.Unknown };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdict of reachability analysis.
|
||||
/// </summary>
|
||||
public enum ReachabilityVerdict
|
||||
{
|
||||
/// <summary>Vulnerable code is reachable - PathWitness provided.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Vulnerable code is not exploitable - SuppressionWitness provided.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Reachability could not be determined.</summary>
|
||||
Unknown
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and verifying DSSE-signed suppression witness envelopes.
|
||||
/// Sprint: SPRINT_20260106_001_002 (SUP-015)
|
||||
/// </summary>
|
||||
public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
{
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SuppressionDsseSigner with the specified signature service.
|
||||
/// </summary>
|
||||
public SuppressionDsseSigner(EnvelopeSignatureService signatureService)
|
||||
{
|
||||
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SuppressionDsseSigner with a default signature service.
|
||||
/// </summary>
|
||||
public SuppressionDsseSigner() : this(new EnvelopeSignatureService())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SuppressionDsseResult SignWitness(SuppressionWitness witness, EnvelopeKey signingKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(witness);
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
// Serialize witness to canonical JSON bytes
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
|
||||
|
||||
// Build the PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = BuildPae(SuppressionWitnessSchema.DssePayloadType, payloadBytes);
|
||||
|
||||
// Sign the PAE
|
||||
var signResult = _signatureService.Sign(pae, signingKey, cancellationToken);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
return SuppressionDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
|
||||
}
|
||||
|
||||
var signature = signResult.Value;
|
||||
|
||||
// Create the DSSE envelope
|
||||
var dsseSignature = new DsseSignature(
|
||||
signature: Convert.ToBase64String(signature.Value.Span),
|
||||
keyId: signature.KeyId);
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
payloadType: SuppressionWitnessSchema.DssePayloadType,
|
||||
payload: payloadBytes,
|
||||
signatures: [dsseSignature]);
|
||||
|
||||
return SuppressionDsseResult.Success(envelope, payloadBytes);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
return SuppressionDsseResult.Failure($"Failed to create DSSE envelope: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SuppressionVerifyResult VerifyWitness(DsseEnvelope envelope, EnvelopeKey publicKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(publicKey);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
// Verify payload type
|
||||
if (!string.Equals(envelope.PayloadType, SuppressionWitnessSchema.DssePayloadType, StringComparison.Ordinal))
|
||||
{
|
||||
return SuppressionVerifyResult.Failure($"Invalid payload type: expected '{SuppressionWitnessSchema.DssePayloadType}', got '{envelope.PayloadType}'");
|
||||
}
|
||||
|
||||
// Deserialize the witness from payload
|
||||
var witness = JsonSerializer.Deserialize<SuppressionWitness>(envelope.Payload.Span, CanonicalJsonOptions);
|
||||
if (witness is null)
|
||||
{
|
||||
return SuppressionVerifyResult.Failure("Failed to deserialize witness from payload");
|
||||
}
|
||||
|
||||
// Verify schema version
|
||||
if (!string.Equals(witness.WitnessSchema, SuppressionWitnessSchema.Version, StringComparison.Ordinal))
|
||||
{
|
||||
return SuppressionVerifyResult.Failure($"Unsupported witness schema: {witness.WitnessSchema}");
|
||||
}
|
||||
|
||||
// Find signature matching the public key
|
||||
var matchingSignature = envelope.Signatures.FirstOrDefault(
|
||||
s => string.Equals(s.KeyId, publicKey.KeyId, StringComparison.Ordinal));
|
||||
|
||||
if (matchingSignature is null)
|
||||
{
|
||||
return SuppressionVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
|
||||
}
|
||||
|
||||
// Build PAE and verify signature
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
|
||||
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.Verify(pae, envelopeSignature, publicKey, cancellationToken);
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
return SuppressionVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
|
||||
}
|
||||
|
||||
return SuppressionVerifyResult.Success(witness, matchingSignature.KeyId!);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException)
|
||||
{
|
||||
return SuppressionVerifyResult.Failure($"Verification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
|
||||
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
// Write "DSSEv1 "
|
||||
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
|
||||
|
||||
// Write len(type) as ASCII decimal string followed by space
|
||||
WriteLengthAndSpace(writer, typeBytes.Length);
|
||||
|
||||
// Write type followed by space
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write len(payload) as ASCII decimal string followed by space
|
||||
WriteLengthAndSpace(writer, payload.Length);
|
||||
|
||||
// Write payload
|
||||
writer.Write(payload);
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
|
||||
{
|
||||
// Write length as ASCII decimal string
|
||||
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
|
||||
writer.Write((byte)' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of DSSE signing a suppression witness.
|
||||
/// </summary>
|
||||
public sealed record SuppressionDsseResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public DsseEnvelope? Envelope { get; init; }
|
||||
public byte[]? PayloadBytes { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static SuppressionDsseResult Success(DsseEnvelope envelope, byte[] payloadBytes)
|
||||
=> new() { IsSuccess = true, Envelope = envelope, PayloadBytes = payloadBytes };
|
||||
|
||||
public static SuppressionDsseResult Failure(string error)
|
||||
=> new() { IsSuccess = false, Error = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a DSSE-signed suppression witness.
|
||||
/// </summary>
|
||||
public sealed record SuppressionVerifyResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public SuppressionWitness? Witness { get; init; }
|
||||
public string? VerifiedKeyId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static SuppressionVerifyResult Success(SuppressionWitness witness, string keyId)
|
||||
=> new() { IsSuccess = true, Witness = witness, VerifiedKeyId = keyId };
|
||||
|
||||
public static SuppressionVerifyResult Failure(string error)
|
||||
=> new() { IsSuccess = false, Error = error };
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// A DSSE-signable suppression witness documenting why a vulnerability is not exploitable.
|
||||
/// Conforms to stellaops.suppression.v1 schema.
|
||||
/// </summary>
|
||||
public sealed record SuppressionWitness
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witness_schema")]
|
||||
public string WitnessSchema { get; init; } = SuppressionWitnessSchema.Version;
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed witness ID (e.g., "sup: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 type of suppression (unreachable, patched, gate-blocked, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("suppression_type")]
|
||||
public required SuppressionType SuppressionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence supporting the suppression claim.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public required SuppressionEvidence Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level in this suppression ([0.0, 1.0]).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiration date for time-bounded suppressions (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this witness was generated (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("observed_at")]
|
||||
public required DateTimeOffset ObservedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional justification narrative.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classification of suppression reasons.
|
||||
/// </summary>
|
||||
public enum SuppressionType
|
||||
{
|
||||
/// <summary>Vulnerable code is unreachable from any entry point.</summary>
|
||||
Unreachable,
|
||||
|
||||
/// <summary>Vulnerable symbol was removed by linker garbage collection.</summary>
|
||||
LinkerGarbageCollected,
|
||||
|
||||
/// <summary>Feature flag disables the vulnerable code path.</summary>
|
||||
FeatureFlagDisabled,
|
||||
|
||||
/// <summary>Vulnerable symbol was patched (backport).</summary>
|
||||
PatchedSymbol,
|
||||
|
||||
/// <summary>Runtime gate (authentication, validation) blocks exploitation.</summary>
|
||||
GateBlocked,
|
||||
|
||||
/// <summary>Compile-time configuration excludes vulnerable code.</summary>
|
||||
CompileTimeExcluded,
|
||||
|
||||
/// <summary>VEX statement from authoritative source declares not_affected.</summary>
|
||||
VexNotAffected,
|
||||
|
||||
/// <summary>Binary does not contain the vulnerable function.</summary>
|
||||
FunctionAbsent,
|
||||
|
||||
/// <summary>Version is outside the affected range.</summary>
|
||||
VersionNotAffected,
|
||||
|
||||
/// <summary>Platform/architecture not vulnerable.</summary>
|
||||
PlatformNotAffected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence supporting a suppression claim. Contains type-specific details.
|
||||
/// </summary>
|
||||
public sealed record SuppressionEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Evidence digests for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witness_evidence")]
|
||||
public required WitnessEvidence WitnessEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unreachability evidence (when SuppressionType is Unreachable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("unreachability")]
|
||||
public UnreachabilityEvidence? Unreachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Patched symbol evidence (when SuppressionType is PatchedSymbol).
|
||||
/// </summary>
|
||||
[JsonPropertyName("patched_symbol")]
|
||||
public PatchedSymbolEvidence? PatchedSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function absence evidence (when SuppressionType is FunctionAbsent).
|
||||
/// </summary>
|
||||
[JsonPropertyName("function_absent")]
|
||||
public FunctionAbsentEvidence? FunctionAbsent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate blocking evidence (when SuppressionType is GateBlocked).
|
||||
/// </summary>
|
||||
[JsonPropertyName("gate_blocked")]
|
||||
public GateBlockedEvidence? GateBlocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feature flag evidence (when SuppressionType is FeatureFlagDisabled).
|
||||
/// </summary>
|
||||
[JsonPropertyName("feature_flag")]
|
||||
public FeatureFlagEvidence? FeatureFlag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement evidence (when SuppressionType is VexNotAffected).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_statement")]
|
||||
public VexStatementEvidence? VexStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version range evidence (when SuppressionType is VersionNotAffected).
|
||||
/// </summary>
|
||||
[JsonPropertyName("version_range")]
|
||||
public VersionRangeEvidence? VersionRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linker GC evidence (when SuppressionType is LinkerGarbageCollected).
|
||||
/// </summary>
|
||||
[JsonPropertyName("linker_gc")]
|
||||
public LinkerGcEvidence? LinkerGc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that vulnerable code is unreachable from any entry point.
|
||||
/// </summary>
|
||||
public sealed record UnreachabilityEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of entrypoints analyzed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzed_entrypoints")]
|
||||
public required int AnalyzedEntrypoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable symbol that was confirmed unreachable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unreachable_symbol")]
|
||||
public required string UnreachableSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis method (static, dynamic, hybrid).
|
||||
/// </summary>
|
||||
[JsonPropertyName("analysis_method")]
|
||||
public required string AnalysisMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph digest for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graph_digest")]
|
||||
public required string GraphDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that vulnerable symbol was patched (backport).
|
||||
/// </summary>
|
||||
public sealed record PatchedSymbolEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerable symbol identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerable_symbol")]
|
||||
public required string VulnerableSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Patched symbol identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("patched_symbol")]
|
||||
public required string PatchedSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol diff showing the patch.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol_diff")]
|
||||
public required string SymbolDiff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Patch commit or release reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("patch_ref")]
|
||||
public string? PatchRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that vulnerable function is absent from the binary.
|
||||
/// </summary>
|
||||
public sealed record FunctionAbsentEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerable function name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("function_name")]
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary digest where function was checked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("binary_digest")]
|
||||
public required string BinaryDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification method (symbol table scan, disassembly, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verification_method")]
|
||||
public required string VerificationMethod { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that runtime gates block exploitation.
|
||||
/// </summary>
|
||||
public sealed record GateBlockedEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Detected gates along all paths to vulnerable code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detected_gates")]
|
||||
public required IReadOnlyList<DetectedGate> DetectedGates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum gate coverage percentage ([0, 100]).
|
||||
/// </summary>
|
||||
[JsonPropertyName("gate_coverage_percent")]
|
||||
public required int GateCoveragePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate effectiveness assessment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("effectiveness")]
|
||||
public required string Effectiveness { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that feature flag disables vulnerable code.
|
||||
/// </summary>
|
||||
public sealed record FeatureFlagEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Feature flag name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("flag_name")]
|
||||
public required string FlagName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag state (enabled, disabled).
|
||||
/// </summary>
|
||||
[JsonPropertyName("flag_state")]
|
||||
public required string FlagState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag configuration source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("config_source")]
|
||||
public required string ConfigSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable code path guarded by flag.
|
||||
/// </summary>
|
||||
[JsonPropertyName("guarded_path")]
|
||||
public string? GuardedPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence from VEX statement declaring not_affected.
|
||||
/// </summary>
|
||||
public sealed record VexStatementEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX document identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_id")]
|
||||
public required string VexId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document author/source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_author")]
|
||||
public required string VexAuthor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_status")]
|
||||
public required string VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification from VEX statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_justification")]
|
||||
public string? VexJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document digest for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_digest")]
|
||||
public string? VexDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that version is outside affected range.
|
||||
/// </summary>
|
||||
public sealed record VersionRangeEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Installed version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("installed_version")]
|
||||
public required string InstalledVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range expression.
|
||||
/// </summary>
|
||||
[JsonPropertyName("affected_range")]
|
||||
public required string AffectedRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed version comparison result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comparison_result")]
|
||||
public required string ComparisonResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version scheme (semver, rpm, deb, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("version_scheme")]
|
||||
public required string VersionScheme { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that linker garbage collection removed vulnerable code.
|
||||
/// </summary>
|
||||
public sealed record LinkerGcEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerable symbol that was collected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("collected_symbol")]
|
||||
public required string CollectedSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linker log or report showing removal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linker_log")]
|
||||
public string? LinkerLog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linker used (ld, lld, link.exe, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("linker")]
|
||||
public required string Linker { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build flags that enabled GC.
|
||||
/// </summary>
|
||||
[JsonPropertyName("build_flags")]
|
||||
public required string BuildFlags { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Builds suppression witnesses from evidence that a vulnerability is not exploitable.
|
||||
/// </summary>
|
||||
public sealed class SuppressionWitnessBuilder : ISuppressionWitnessBuilder
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SuppressionWitnessBuilder.
|
||||
/// </summary>
|
||||
/// <param name="cryptoHash">Crypto hash service for witness ID generation.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
public SuppressionWitnessBuilder(ICryptoHash cryptoHash, TimeProvider timeProvider)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionWitness> BuildUnreachableAsync(
|
||||
UnreachabilityRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = CreateWitnessEvidence(request.GraphDigest),
|
||||
Unreachability = new UnreachabilityEvidence
|
||||
{
|
||||
AnalyzedEntrypoints = request.AnalyzedEntrypoints,
|
||||
UnreachableSymbol = request.UnreachableSymbol,
|
||||
AnalysisMethod = request.AnalysisMethod,
|
||||
GraphDigest = request.GraphDigest
|
||||
}
|
||||
};
|
||||
|
||||
var witness = CreateWitness(request, SuppressionType.Unreachable, evidence, request.Confidence);
|
||||
return Task.FromResult(witness);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionWitness> BuildPatchedSymbolAsync(
|
||||
PatchedSymbolRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var symbolDiffDigest = ComputeStringDigest(request.SymbolDiff);
|
||||
var evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = CreateWitnessEvidence(symbolDiffDigest),
|
||||
PatchedSymbol = new PatchedSymbolEvidence
|
||||
{
|
||||
VulnerableSymbol = request.VulnerableSymbol,
|
||||
PatchedSymbol = request.PatchedSymbol,
|
||||
SymbolDiff = request.SymbolDiff,
|
||||
PatchRef = request.PatchRef
|
||||
}
|
||||
};
|
||||
|
||||
var witness = CreateWitness(request, SuppressionType.PatchedSymbol, evidence, request.Confidence);
|
||||
return Task.FromResult(witness);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionWitness> BuildFunctionAbsentAsync(
|
||||
FunctionAbsentRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = CreateWitnessEvidence(request.BinaryDigest),
|
||||
FunctionAbsent = new FunctionAbsentEvidence
|
||||
{
|
||||
FunctionName = request.FunctionName,
|
||||
BinaryDigest = request.BinaryDigest,
|
||||
VerificationMethod = request.VerificationMethod
|
||||
}
|
||||
};
|
||||
|
||||
var witness = CreateWitness(request, SuppressionType.FunctionAbsent, evidence, request.Confidence);
|
||||
return Task.FromResult(witness);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionWitness> BuildGateBlockedAsync(
|
||||
GateBlockedRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var gatesDigest = ComputeGatesDigest(request.DetectedGates);
|
||||
var evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = CreateWitnessEvidence(gatesDigest),
|
||||
GateBlocked = new GateBlockedEvidence
|
||||
{
|
||||
DetectedGates = request.DetectedGates,
|
||||
GateCoveragePercent = request.GateCoveragePercent,
|
||||
Effectiveness = request.Effectiveness
|
||||
}
|
||||
};
|
||||
|
||||
var witness = CreateWitness(request, SuppressionType.GateBlocked, evidence, request.Confidence);
|
||||
return Task.FromResult(witness);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionWitness> BuildFeatureFlagDisabledAsync(
|
||||
FeatureFlagRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var flagDigest = ComputeStringDigest($"{request.FlagName}={request.FlagState}@{request.ConfigSource}");
|
||||
var evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = CreateWitnessEvidence(flagDigest),
|
||||
FeatureFlag = new FeatureFlagEvidence
|
||||
{
|
||||
FlagName = request.FlagName,
|
||||
FlagState = request.FlagState,
|
||||
ConfigSource = request.ConfigSource,
|
||||
GuardedPath = request.GuardedPath
|
||||
}
|
||||
};
|
||||
|
||||
var witness = CreateWitness(request, SuppressionType.FeatureFlagDisabled, evidence, request.Confidence);
|
||||
return Task.FromResult(witness);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionWitness> BuildFromVexStatementAsync(
|
||||
VexStatementRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = CreateWitnessEvidence(request.VexDigest ?? request.VexId),
|
||||
VexStatement = new VexStatementEvidence
|
||||
{
|
||||
VexId = request.VexId,
|
||||
VexAuthor = request.VexAuthor,
|
||||
VexStatus = request.VexStatus,
|
||||
VexJustification = request.VexJustification,
|
||||
VexDigest = request.VexDigest
|
||||
}
|
||||
};
|
||||
|
||||
var witness = CreateWitness(request, SuppressionType.VexNotAffected, evidence, request.Confidence);
|
||||
return Task.FromResult(witness);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionWitness> BuildVersionNotAffectedAsync(
|
||||
VersionRangeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var versionDigest = ComputeStringDigest($"{request.InstalledVersion}@{request.AffectedRange}");
|
||||
var evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = CreateWitnessEvidence(versionDigest),
|
||||
VersionRange = new VersionRangeEvidence
|
||||
{
|
||||
InstalledVersion = request.InstalledVersion,
|
||||
AffectedRange = request.AffectedRange,
|
||||
ComparisonResult = request.ComparisonResult,
|
||||
VersionScheme = request.VersionScheme
|
||||
}
|
||||
};
|
||||
|
||||
var witness = CreateWitness(request, SuppressionType.VersionNotAffected, evidence, request.Confidence);
|
||||
return Task.FromResult(witness);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionWitness> BuildLinkerGarbageCollectedAsync(
|
||||
LinkerGcRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var gcDigest = ComputeStringDigest($"{request.CollectedSymbol}@{request.Linker}@{request.BuildFlags}");
|
||||
var evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = CreateWitnessEvidence(gcDigest),
|
||||
LinkerGc = new LinkerGcEvidence
|
||||
{
|
||||
CollectedSymbol = request.CollectedSymbol,
|
||||
LinkerLog = request.LinkerLog,
|
||||
Linker = request.Linker,
|
||||
BuildFlags = request.BuildFlags
|
||||
}
|
||||
};
|
||||
|
||||
var witness = CreateWitness(request, SuppressionType.LinkerGarbageCollected, evidence, request.Confidence);
|
||||
return Task.FromResult(witness);
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private SuppressionWitness CreateWitness(
|
||||
BaseSuppressionRequest request,
|
||||
SuppressionType type,
|
||||
SuppressionEvidence evidence,
|
||||
double confidence)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var witness = new SuppressionWitness
|
||||
{
|
||||
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
|
||||
},
|
||||
SuppressionType = type,
|
||||
Evidence = evidence,
|
||||
Confidence = Math.Clamp(confidence, 0.0, 1.0),
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
ObservedAt = now,
|
||||
Justification = request.Justification
|
||||
};
|
||||
|
||||
// Compute content-addressed witness ID
|
||||
var canonicalJson = JsonSerializer.Serialize(witness, JsonOptions);
|
||||
var witnessIdDigest = _cryptoHash.ComputeHash(Encoding.UTF8.GetBytes(canonicalJson));
|
||||
var witnessId = $"sup:sha256:{Convert.ToHexString(witnessIdDigest).ToLowerInvariant()}";
|
||||
|
||||
return witness with { WitnessId = witnessId };
|
||||
}
|
||||
|
||||
private WitnessEvidence CreateWitnessEvidence(string primaryDigest)
|
||||
{
|
||||
return new WitnessEvidence
|
||||
{
|
||||
CallgraphDigest = primaryDigest,
|
||||
BuildId = $"StellaOps.Scanner/{GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0"}"
|
||||
};
|
||||
}
|
||||
|
||||
private string ComputeStringDigest(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = _cryptoHash.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string ComputeGatesDigest(IReadOnlyList<DetectedGate> gates)
|
||||
{
|
||||
// Serialize gates in deterministic order
|
||||
var sortedGates = gates.OrderBy(g => g.Type).ThenBy(g => g.GuardSymbol).ToList();
|
||||
var json = JsonSerializer.Serialize(sortedGates, JsonOptions);
|
||||
var hash = _cryptoHash.ComputeHash(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for SuppressionWitness documents.
|
||||
/// </summary>
|
||||
public static class SuppressionWitnessSchema
|
||||
{
|
||||
/// <summary>
|
||||
/// Current stellaops.suppression schema version.
|
||||
/// </summary>
|
||||
public const string Version = "stellaops.suppression.v1";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type for suppression witnesses.
|
||||
/// </summary>
|
||||
public const string DssePayloadType = "https://stellaops.org/suppression/v1";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering suppression witness services.
|
||||
/// Sprint: SPRINT_20260106_001_002 (SUP-019)
|
||||
/// </summary>
|
||||
public static class SuppressionWitnessServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds suppression witness services to the dependency injection container.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSuppressionWitnessServices(this IServiceCollection services)
|
||||
{
|
||||
// Register builder
|
||||
services.AddSingleton<ISuppressionWitnessBuilder, SuppressionWitnessBuilder>();
|
||||
|
||||
// Register DSSE signer
|
||||
services.AddSingleton<ISuppressionDsseSigner, SuppressionDsseSigner>();
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user