doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -12,7 +12,7 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
private readonly ILogger<InMemorySliceCache> _logger;
private readonly TimeProvider _timeProvider;
private readonly CancellationTokenSource _evictionCts = new();
private readonly Timer _evictionTimer;
private readonly ITimer _evictionTimer;
private readonly SemaphoreSlim _evictionLock = new(1, 1);
private long _hitCount;
@@ -26,7 +26,7 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_evictionTimer = new Timer(
_evictionTimer = _timeProvider.CreateTimer(
_ => _ = EvictExpiredEntriesAsync(_evictionCts.Token),
null,
TimeSpan.FromSeconds(EvictionIntervalSeconds),

View File

@@ -32,7 +32,7 @@ public sealed class SliceCache : ISliceCache, IDisposable
private readonly SliceCacheOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, CacheItem> _cache = new(StringComparer.Ordinal);
private readonly Timer _evictionTimer;
private readonly ITimer _evictionTimer;
private long _hitCount;
private long _missCount;
private bool _disposed;
@@ -41,7 +41,7 @@ public sealed class SliceCache : ISliceCache, IDisposable
{
_options = options?.Value ?? new SliceCacheOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_evictionTimer = new Timer(EvictExpired, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
_evictionTimer = _timeProvider.CreateTimer(EvictExpired, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public Task<CachedSliceResult?> TryGetAsync(string cacheKey, CancellationToken cancellationToken = default)

View File

@@ -0,0 +1,147 @@
// -----------------------------------------------------------------------------
// ClaimIdGenerator.cs
// Sprint: SPRINT_20260118_015_Scanner_runtime_witness_model
// Task: TASK-015-002 - Add claim_id field for static-runtime linkage
// Description: Generates deterministic claim IDs for linking witnesses
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Generates deterministic claim IDs for linking runtime witnesses to static claims.
/// </summary>
public static class ClaimIdGenerator
{
/// <summary>
/// Claim ID prefix.
/// </summary>
public const string Prefix = "claim";
/// <summary>
/// Generates a deterministic claim ID from artifact digest and path hash.
/// </summary>
/// <param name="artifactDigest">SHA-256 digest of the artifact (SBOM).</param>
/// <param name="pathHash">Path hash from PathWitnessBuilder.</param>
/// <returns>Claim ID in format "claim:&lt;artifact-digest&gt;:&lt;path-hash&gt;".</returns>
public static string Generate(string artifactDigest, string pathHash)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(pathHash);
// Normalize inputs
var normalizedArtifact = NormalizeDigest(artifactDigest);
var normalizedPath = NormalizeHash(pathHash);
return $"{Prefix}:{normalizedArtifact}:{normalizedPath}";
}
/// <summary>
/// Generates a claim ID from witness artifact and path hash.
/// </summary>
/// <param name="artifact">Witness artifact containing SBOM digest.</param>
/// <param name="pathHash">Path hash from witness.</param>
/// <returns>Claim ID.</returns>
public static string Generate(WitnessArtifact artifact, string pathHash)
{
ArgumentNullException.ThrowIfNull(artifact);
return Generate(artifact.SbomDigest, pathHash);
}
/// <summary>
/// Generates a claim ID directly from a path witness that has a path hash.
/// </summary>
/// <param name="witness">Path witness with PathHash set.</param>
/// <returns>Claim ID.</returns>
/// <exception cref="InvalidOperationException">If PathHash is not set.</exception>
public static string Generate(PathWitness witness)
{
ArgumentNullException.ThrowIfNull(witness);
if (string.IsNullOrWhiteSpace(witness.PathHash))
{
throw new InvalidOperationException(
"Cannot generate claim ID: PathHash is not set on witness.");
}
return Generate(witness.Artifact.SbomDigest, witness.PathHash);
}
/// <summary>
/// Parses a claim ID into its components.
/// </summary>
/// <param name="claimId">The claim ID to parse.</param>
/// <returns>Tuple of (artifactDigest, pathHash).</returns>
/// <exception cref="FormatException">If claim ID format is invalid.</exception>
public static (string ArtifactDigest, string PathHash) Parse(string claimId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(claimId);
var parts = claimId.Split(':');
if (parts.Length != 3 || parts[0] != Prefix)
{
throw new FormatException(
$"Invalid claim ID format. Expected '{Prefix}:<artifact-digest>:<path-hash>', got '{claimId}'.");
}
return (parts[1], parts[2]);
}
/// <summary>
/// Validates that a claim ID is well-formed.
/// </summary>
/// <param name="claimId">The claim ID to validate.</param>
/// <returns>True if valid, false otherwise.</returns>
public static bool IsValid(string? claimId)
{
if (string.IsNullOrWhiteSpace(claimId))
return false;
var parts = claimId.Split(':');
return parts.Length == 3
&& parts[0] == Prefix
&& !string.IsNullOrWhiteSpace(parts[1])
&& !string.IsNullOrWhiteSpace(parts[2]);
}
/// <summary>
/// Computes a combined hash for linking multiple claims.
/// </summary>
/// <param name="claimIds">Collection of claim IDs to link.</param>
/// <returns>SHA-256 hash of sorted, concatenated claim IDs.</returns>
public static string ComputeLinkHash(IEnumerable<string> claimIds)
{
ArgumentNullException.ThrowIfNull(claimIds);
var sorted = claimIds.OrderBy(c => c, StringComparer.Ordinal).ToList();
if (sorted.Count == 0)
return string.Empty;
var combined = string.Join("\n", sorted);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string NormalizeDigest(string digest)
{
// Remove common prefixes like "sha256:"
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
digest = digest[7..];
if (digest.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase))
digest = digest[7..];
return digest.ToLowerInvariant();
}
private static string NormalizeHash(string hash)
{
// Remove any prefixes and normalize case
if (hash.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
hash = hash[2..];
return hash.ToLowerInvariant();
}
}

View File

@@ -0,0 +1,158 @@
// -----------------------------------------------------------------------------
// IRuntimeWitnessGenerator.cs
// Sprint: SPRINT_20260118_016_Scanner_runtime_witness_signing
// Task: TASK-016-003 - Extend SignedWitnessGenerator for runtime requests
// Description: Interface for runtime witness generation
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Generates signed runtime witnesses from observation data.
/// </summary>
public interface IRuntimeWitnessGenerator
{
/// <summary>
/// Generates a single signed runtime witness.
/// </summary>
/// <param name="request">The runtime witness request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The signed witness result.</returns>
Task<RuntimeWitnessResult> GenerateAsync(
RuntimeWitnessRequest request,
CancellationToken ct = default);
/// <summary>
/// Generates multiple signed runtime witnesses.
/// </summary>
/// <param name="request">The batch request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Async enumerable of signed witness results.</returns>
IAsyncEnumerable<RuntimeWitnessResult> GenerateBatchAsync(
BatchRuntimeWitnessRequest request,
CancellationToken ct = default);
/// <summary>
/// Generates runtime witnesses from a stream of observations.
/// </summary>
/// <param name="observations">Stream of runtime observations.</param>
/// <param name="contextProvider">Provider for witness context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Async enumerable of signed witness results.</returns>
IAsyncEnumerable<RuntimeWitnessResult> GenerateFromStreamAsync(
IAsyncEnumerable<RuntimeObservation> observations,
IRuntimeWitnessContextProvider contextProvider,
CancellationToken ct = default);
}
/// <summary>
/// Result of runtime witness generation.
/// </summary>
public sealed record RuntimeWitnessResult
{
/// <summary>
/// Whether the generation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The generated witness (if successful).
/// </summary>
public PathWitness? Witness { get; init; }
/// <summary>
/// The signed DSSE envelope (if successful).
/// </summary>
public byte[]? EnvelopeBytes { get; init; }
/// <summary>
/// Rekor log entry index (if published).
/// </summary>
public long? RekorLogIndex { get; init; }
/// <summary>
/// Rekor log ID (if published).
/// </summary>
public string? RekorLogId { get; init; }
/// <summary>
/// Content-addressed storage URI (if stored).
/// </summary>
public string? CasUri { get; init; }
/// <summary>
/// Error message (if failed).
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// The original claim ID.
/// </summary>
public string? ClaimId { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static RuntimeWitnessResult Successful(
PathWitness witness,
byte[] envelopeBytes,
long? rekorLogIndex = null,
string? rekorLogId = null,
string? casUri = null)
{
return new RuntimeWitnessResult
{
Success = true,
Witness = witness,
EnvelopeBytes = envelopeBytes,
RekorLogIndex = rekorLogIndex,
RekorLogId = rekorLogId,
CasUri = casUri,
ClaimId = witness.ClaimId
};
}
/// <summary>
/// Creates a failed result.
/// </summary>
public static RuntimeWitnessResult Failed(string claimId, string errorMessage)
{
return new RuntimeWitnessResult
{
Success = false,
ErrorMessage = errorMessage,
ClaimId = claimId
};
}
}
/// <summary>
/// Provides context for runtime witness generation from observation streams.
/// </summary>
public interface IRuntimeWitnessContextProvider
{
/// <summary>
/// Gets the claim ID for an observation.
/// </summary>
string GetClaimId(RuntimeObservation observation);
/// <summary>
/// Gets the artifact digest for context.
/// </summary>
string GetArtifactDigest();
/// <summary>
/// Gets the component PURL being observed.
/// </summary>
string GetComponentPurl(RuntimeObservation observation);
/// <summary>
/// Gets the vulnerability ID if applicable.
/// </summary>
string? GetVulnerabilityId(RuntimeObservation observation);
/// <summary>
/// Gets signing options.
/// </summary>
RuntimeWitnessSigningOptions GetSigningOptions();
}

View File

@@ -0,0 +1,280 @@
// -----------------------------------------------------------------------------
// IWitnessVerifier.cs
// Sprint: SPRINT_20260118_017_Scanner_witness_verifier
// Task: TASK-017-001 - Create IWitnessVerifier interface and WitnessMatchResult
// Description: Interface for verifying and matching runtime witnesses to claims
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Verifies and matches runtime witnesses to static reachability claims.
/// </summary>
public interface IWitnessVerifier
{
/// <summary>
/// Find and verify all runtime witnesses for a claim.
/// </summary>
/// <param name="claimId">The claim ID to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result with matched witnesses.</returns>
Task<WitnessVerificationResult> VerifyAsync(string claimId, CancellationToken ct = default);
/// <summary>
/// Verify a specific Rekor entry.
/// </summary>
/// <param name="logIndex">The Rekor log index.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result for the entry.</returns>
Task<WitnessVerificationResult> VerifyByLogIndexAsync(long logIndex, CancellationToken ct = default);
/// <summary>
/// Check if a witness matches a claim.
/// </summary>
/// <param name="witness">The path witness to check.</param>
/// <param name="claimId">The claim ID to match against.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Match result with confidence score.</returns>
Task<WitnessMatchResult> MatchWitnessToClaimAsync(
PathWitness witness,
string claimId,
CancellationToken ct = default);
/// <summary>
/// Batch verify multiple claims.
/// </summary>
/// <param name="claimIds">The claim IDs to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Async enumerable of verification results.</returns>
IAsyncEnumerable<WitnessVerificationResult> VerifyBatchAsync(
IEnumerable<string> claimIds,
CancellationToken ct = default);
}
/// <summary>
/// Result of witness verification.
/// </summary>
public sealed record WitnessVerificationResult
{
/// <summary>
/// The claim ID that was verified.
/// </summary>
public required string ClaimId { get; init; }
/// <summary>
/// Overall verification status.
/// </summary>
public required WitnessVerificationStatus Status { get; init; }
/// <summary>
/// Matched witnesses for this claim.
/// </summary>
public IReadOnlyList<WitnessMatchResult> Matches { get; init; } = [];
/// <summary>
/// Best match (highest confidence).
/// </summary>
public WitnessMatchResult? BestMatch { get; init; }
/// <summary>
/// Verification timestamp.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Error message if verification failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static WitnessVerificationResult Success(
string claimId,
IReadOnlyList<WitnessMatchResult> matches)
{
var best = matches.MaxBy(m => m.Confidence);
return new WitnessVerificationResult
{
ClaimId = claimId,
Status = matches.Count > 0
? WitnessVerificationStatus.Verified
: WitnessVerificationStatus.NoWitnessFound,
Matches = matches,
BestMatch = best
};
}
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static WitnessVerificationResult Failed(string claimId, string errorMessage)
{
return new WitnessVerificationResult
{
ClaimId = claimId,
Status = WitnessVerificationStatus.Failed,
ErrorMessage = errorMessage
};
}
}
/// <summary>
/// Overall verification status.
/// </summary>
public enum WitnessVerificationStatus
{
/// <summary>Witness found and verified.</summary>
Verified,
/// <summary>Verification in progress.</summary>
Pending,
/// <summary>No witness found for claim.</summary>
NoWitnessFound,
/// <summary>Witness found but verification failed.</summary>
Failed,
/// <summary>Witness signature invalid.</summary>
SignatureInvalid,
/// <summary>Witness expired (outside time window).</summary>
Expired
}
/// <summary>
/// Result of matching a witness to a claim.
/// </summary>
public sealed record WitnessMatchResult
{
/// <summary>
/// Match status.
/// </summary>
public required WitnessMatchStatus MatchStatus { get; init; }
/// <summary>
/// The matched witness.
/// </summary>
public PathWitness? Witness { get; init; }
/// <summary>
/// Match confidence score (0.0-1.0).
/// 1.0 = exact PathHash match
/// 0.8+ = high node hash overlap
/// </summary>
public double Confidence { get; init; }
/// <summary>
/// List of matched function/method IDs.
/// </summary>
public IReadOnlyList<string> MatchedFrames { get; init; } = [];
/// <summary>
/// List of discrepancies between claim and witness.
/// </summary>
public IReadOnlyList<WitnessDiscrepancy> Discrepancies { get; init; } = [];
/// <summary>
/// Rekor log index (if anchored).
/// </summary>
public long? RekorLogIndex { get; init; }
/// <summary>
/// Whether Rekor verification passed.
/// </summary>
public bool RekorVerified { get; init; }
/// <summary>
/// DSSE signature verification status.
/// </summary>
public bool SignatureVerified { get; init; }
/// <summary>
/// Integrated timestamp from Rekor.
/// </summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>
/// Creates an exact match result.
/// </summary>
public static WitnessMatchResult ExactMatch(PathWitness witness, long? rekorLogIndex = null)
{
return new WitnessMatchResult
{
MatchStatus = WitnessMatchStatus.Matched,
Witness = witness,
Confidence = 1.0,
RekorLogIndex = rekorLogIndex,
RekorVerified = rekorLogIndex.HasValue,
SignatureVerified = true
};
}
/// <summary>
/// Creates a no-match result.
/// </summary>
public static WitnessMatchResult NoMatch(string reason)
{
return new WitnessMatchResult
{
MatchStatus = WitnessMatchStatus.NoMatch,
Confidence = 0.0,
Discrepancies = [new WitnessDiscrepancy { Type = "NoMatch", Description = reason }]
};
}
}
/// <summary>
/// Match status between witness and claim.
/// </summary>
public enum WitnessMatchStatus
{
/// <summary>Exact match on PathHash.</summary>
Matched,
/// <summary>Partial match on NodeHashes.</summary>
PartialMatch,
/// <summary>No match found.</summary>
NoMatch,
/// <summary>Match found but verification failed.</summary>
VerificationFailed
}
/// <summary>
/// A discrepancy between claim and witness.
/// </summary>
public sealed record WitnessDiscrepancy
{
/// <summary>Discrepancy type.</summary>
public required string Type { get; init; }
/// <summary>Description of the discrepancy.</summary>
public required string Description { get; init; }
/// <summary>Expected value (from claim).</summary>
public string? Expected { get; init; }
/// <summary>Actual value (from witness).</summary>
public string? Actual { get; init; }
/// <summary>Severity of the discrepancy.</summary>
public DiscrepancySeverity Severity { get; init; } = DiscrepancySeverity.Warning;
}
/// <summary>
/// Severity of a discrepancy.
/// </summary>
public enum DiscrepancySeverity
{
/// <summary>Informational difference.</summary>
Info,
/// <summary>Minor difference that may affect match confidence.</summary>
Warning,
/// <summary>Significant difference that prevents match.</summary>
Error
}

View File

@@ -0,0 +1,33 @@
// -----------------------------------------------------------------------------
// ObservationType.cs
// Sprint: SPRINT_20260118_015_Scanner_runtime_witness_model
// Task: TASK-015-001 - Add ObservationType enum and field to PathWitness
// Description: Discriminator for static vs runtime vs confirmed paths
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Indicates how a path witness was observed.
/// Follows the discriminated union pattern used by <see cref="SuppressionType"/>.
/// </summary>
public enum ObservationType
{
/// <summary>
/// Path discovered through static call graph analysis only.
/// Default value for backward compatibility with legacy witnesses.
/// </summary>
Static = 0,
/// <summary>
/// Path observed at runtime only (e.g., via Tetragon, OTel, profiler).
/// May not have corresponding static analysis evidence.
/// </summary>
Runtime = 1,
/// <summary>
/// Static path confirmed by runtime observation.
/// Highest confidence level - both static analysis and runtime execution agree.
/// </summary>
Confirmed = 2
}

View File

@@ -127,6 +127,29 @@ public sealed record PathWitness
/// </summary>
[JsonPropertyName("predicate_type")]
public string PredicateType { get; init; } = WitnessPredicateTypes.PathWitnessCanonical;
/// <summary>
/// How this path was observed (static analysis, runtime, or confirmed by both).
/// Sprint: SPRINT_20260118_015_Scanner_runtime_witness_model (TASK-015-001)
/// </summary>
[JsonPropertyName("observation_type")]
public ObservationType ObservationType { get; init; } = ObservationType.Static;
/// <summary>
/// Claim ID for linking runtime witnesses to static claims.
/// Format: "claim:&lt;artifact-digest&gt;:&lt;path-hash&gt;".
/// Sprint: SPRINT_20260118_015_Scanner_runtime_witness_model (TASK-015-002)
/// </summary>
[JsonPropertyName("claim_id")]
public string? ClaimId { get; init; }
/// <summary>
/// Runtime observations that confirmed or discovered this path.
/// Empty for purely static witnesses.
/// Sprint: SPRINT_20260118_015_Scanner_runtime_witness_model (TASK-015-003)
/// </summary>
[JsonPropertyName("observations")]
public IReadOnlyList<RuntimeObservation>? Observations { get; init; }
}
/// <summary>

View File

@@ -0,0 +1,98 @@
// -----------------------------------------------------------------------------
// RuntimeObservation.cs
// Sprint: SPRINT_20260118_015_Scanner_runtime_witness_model
// Task: TASK-015-003 - Create RuntimeObservation record
// Description: Wraps runtime call events for witness evidence
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// A runtime observation record that wraps/extends RuntimeCallEvent data.
/// Used to attach runtime evidence to path witnesses.
/// </summary>
public sealed record RuntimeObservation
{
/// <summary>
/// Timestamp when the observation was recorded (UTC).
/// </summary>
[JsonPropertyName("observed_at")]
public required DateTimeOffset ObservedAt { get; init; }
/// <summary>
/// Number of times this path was observed (aggregated count).
/// </summary>
[JsonPropertyName("observation_count")]
public int ObservationCount { get; init; } = 1;
/// <summary>
/// SHA-256 hash of stack frame signatures for verification.
/// </summary>
[JsonPropertyName("stack_sample_hash")]
public string? StackSampleHash { get; init; }
/// <summary>
/// Process ID where the path was observed.
/// </summary>
[JsonPropertyName("process_id")]
public int? ProcessId { get; init; }
/// <summary>
/// Container ID where the path was observed.
/// </summary>
[JsonPropertyName("container_id")]
public string? ContainerId { get; init; }
/// <summary>
/// Kubernetes pod name if running in K8s.
/// </summary>
[JsonPropertyName("pod_name")]
public string? PodName { get; init; }
/// <summary>
/// Kubernetes namespace if running in K8s.
/// </summary>
[JsonPropertyName("namespace")]
public string? Namespace { get; init; }
/// <summary>
/// Source type of the observation.
/// </summary>
[JsonPropertyName("source_type")]
public required RuntimeObservationSourceType SourceType { get; init; }
/// <summary>
/// Unique observation ID for deduplication.
/// </summary>
[JsonPropertyName("observation_id")]
public string? ObservationId { get; init; }
/// <summary>
/// Duration of the observed call in microseconds (if available).
/// </summary>
[JsonPropertyName("duration_us")]
public long? DurationMicroseconds { get; init; }
}
/// <summary>
/// Source type for runtime observations.
/// </summary>
public enum RuntimeObservationSourceType
{
/// <summary>Observation from Tetragon eBPF.</summary>
Tetragon,
/// <summary>Observation from OpenTelemetry tracing.</summary>
OpenTelemetry,
/// <summary>Observation from profiler sampling.</summary>
Profiler,
/// <summary>Observation from APM tracer.</summary>
Tracer,
/// <summary>Observation from custom instrumentation.</summary>
Custom
}

View File

@@ -0,0 +1,66 @@
// -----------------------------------------------------------------------------
// RuntimeWitnessPredicateTypes.cs
// Sprint: SPRINT_20260118_016_Scanner_runtime_witness_signing
// Task: TASK-016-002 - Define runtime witness predicate type
// Description: Predicate types for runtime witness attestations
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Well-known predicate types for runtime witness attestations.
/// </summary>
public static class RuntimeWitnessPredicateTypes
{
/// <summary>
/// Canonical runtime witness predicate type URI.
/// </summary>
public const string RuntimeWitnessCanonical = "https://stella.ops/predicates/runtime-witness/v1";
/// <summary>
/// Alias for runtime witness predicate type.
/// </summary>
public const string RuntimeWitnessAlias = "stella.ops/runtimeWitness@v1";
/// <summary>
/// Confirmed witness predicate type (static + runtime match).
/// </summary>
public const string ConfirmedWitnessCanonical = "https://stella.ops/predicates/confirmed-witness/v1";
/// <summary>
/// Alias for confirmed witness predicate type.
/// </summary>
public const string ConfirmedWitnessAlias = "stella.ops/confirmedWitness@v1";
/// <summary>
/// Returns true if the predicate type is a recognized runtime witness type.
/// </summary>
public static bool IsRuntimeWitnessType(string predicateType)
{
return predicateType == RuntimeWitnessCanonical
|| predicateType == RuntimeWitnessAlias;
}
/// <summary>
/// Returns true if the predicate type is a recognized confirmed witness type.
/// </summary>
public static bool IsConfirmedWitnessType(string predicateType)
{
return predicateType == ConfirmedWitnessCanonical
|| predicateType == ConfirmedWitnessAlias;
}
/// <summary>
/// Gets the appropriate predicate type for an observation type.
/// </summary>
public static string GetPredicateType(ObservationType observationType)
{
return observationType switch
{
ObservationType.Static => WitnessPredicateTypes.PathWitnessCanonical,
ObservationType.Runtime => RuntimeWitnessCanonical,
ObservationType.Confirmed => ConfirmedWitnessCanonical,
_ => WitnessPredicateTypes.PathWitnessCanonical
};
}
}

View File

@@ -0,0 +1,135 @@
// -----------------------------------------------------------------------------
// RuntimeWitnessRequest.cs
// Sprint: SPRINT_20260118_016_Scanner_runtime_witness_signing
// Task: TASK-016-001 - Create RuntimeWitnessRequest model
// Description: Request model for runtime witness generation
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Request to generate a signed runtime witness.
/// Follows existing PathWitnessRequest pattern.
/// </summary>
public sealed record RuntimeWitnessRequest
{
/// <summary>
/// Claim ID linking to the static claim being witnessed.
/// Format: "claim:&lt;artifact-digest&gt;:&lt;path-hash&gt;".
/// </summary>
public required string ClaimId { get; init; }
/// <summary>
/// SHA-256 digest of the artifact (SBOM/image) for context.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Component PURL being observed.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Runtime observations to include in the witness.
/// Must have at least one observation.
/// </summary>
public required IReadOnlyList<RuntimeObservation> Observations { get; init; }
/// <summary>
/// Vulnerability ID if observing a vulnerable path.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Whether to publish to Rekor transparency log.
/// </summary>
public bool PublishToRekor { get; init; } = true;
/// <summary>
/// Signing options.
/// </summary>
public RuntimeWitnessSigningOptions SigningOptions { get; init; } = new();
/// <summary>
/// Validates the request.
/// </summary>
/// <exception cref="ArgumentException">If validation fails.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(ClaimId))
throw new ArgumentException("ClaimId is required.", nameof(ClaimId));
if (!ClaimIdGenerator.IsValid(ClaimId))
throw new ArgumentException($"Invalid ClaimId format: {ClaimId}", nameof(ClaimId));
if (string.IsNullOrWhiteSpace(ArtifactDigest))
throw new ArgumentException("ArtifactDigest is required.", nameof(ArtifactDigest));
if (string.IsNullOrWhiteSpace(ComponentPurl))
throw new ArgumentException("ComponentPurl is required.", nameof(ComponentPurl));
if (Observations == null || Observations.Count == 0)
throw new ArgumentException("At least one observation is required.", nameof(Observations));
}
}
/// <summary>
/// Signing options for runtime witnesses.
/// </summary>
public sealed record RuntimeWitnessSigningOptions
{
/// <summary>
/// Key ID for signing (null for keyless/ephemeral).
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Whether to use keyless (Fulcio) signing.
/// </summary>
public bool UseKeyless { get; init; } = true;
/// <summary>
/// Signature algorithm preference.
/// </summary>
public string Algorithm { get; init; } = "ECDSA_P256_SHA256";
/// <summary>
/// Timeout for signing operations.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Request to generate multiple runtime witnesses in batch.
/// </summary>
public sealed record BatchRuntimeWitnessRequest
{
/// <summary>
/// Individual runtime witness requests.
/// </summary>
public required IReadOnlyList<RuntimeWitnessRequest> Requests { get; init; }
/// <summary>
/// Maximum parallelism for batch processing.
/// </summary>
public int MaxParallelism { get; init; } = 4;
/// <summary>
/// Whether to continue on individual failures.
/// </summary>
public bool ContinueOnError { get; init; } = true;
/// <summary>
/// Validates all requests in the batch.
/// </summary>
public void Validate()
{
if (Requests == null || Requests.Count == 0)
throw new ArgumentException("At least one request is required.", nameof(Requests));
foreach (var request in Requests)
{
request.Validate();
}
}
}

View File

@@ -0,0 +1,199 @@
// -----------------------------------------------------------------------------
// WitnessMatcher.cs
// Sprint: SPRINT_20260118_017_Scanner_witness_verifier
// Task: TASK-017-002 - Implement claim-to-witness matching using PathHash
// Description: Matches runtime witnesses to claims using path hashes
// -----------------------------------------------------------------------------
using System.Globalization;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Matches runtime witnesses to static claims using PathHash and NodeHashes.
/// </summary>
public sealed class WitnessMatcher
{
/// <summary>
/// Threshold for high-confidence partial match.
/// </summary>
public const double HighConfidenceThreshold = 0.8;
/// <summary>
/// Threshold for accepting a partial match.
/// </summary>
public const double PartialMatchThreshold = 0.5;
/// <summary>
/// Matches a witness to a claim ID.
/// </summary>
/// <param name="witness">The path witness to match.</param>
/// <param name="claimId">The claim ID to match against.</param>
/// <returns>Match result with confidence score.</returns>
public WitnessMatchResult Match(PathWitness witness, string claimId)
{
ArgumentNullException.ThrowIfNull(witness);
ArgumentException.ThrowIfNullOrWhiteSpace(claimId);
// Parse claim ID to extract artifact digest and path hash
if (!ClaimIdGenerator.IsValid(claimId))
{
return WitnessMatchResult.NoMatch($"Invalid claim ID format: {claimId}");
}
var (artifactDigest, expectedPathHash) = ClaimIdGenerator.Parse(claimId);
// Check artifact digest match
if (!ArtifactDigestMatches(witness, artifactDigest))
{
return CreateNoMatch("Artifact digest mismatch", artifactDigest, witness.Artifact.SbomDigest);
}
// Check exact PathHash match
if (!string.IsNullOrEmpty(witness.PathHash))
{
var normalizedWitnessHash = NormalizeHash(witness.PathHash);
var normalizedExpectedHash = NormalizeHash(expectedPathHash);
if (normalizedWitnessHash == normalizedExpectedHash)
{
return WitnessMatchResult.ExactMatch(witness);
}
}
// Try partial match using NodeHashes
if (witness.NodeHashes != null && witness.NodeHashes.Count > 0)
{
var confidence = ComputeNodeHashConfidence(witness.NodeHashes, expectedPathHash);
if (confidence >= PartialMatchThreshold)
{
return new WitnessMatchResult
{
MatchStatus = confidence >= HighConfidenceThreshold
? WitnessMatchStatus.Matched
: WitnessMatchStatus.PartialMatch,
Witness = witness,
Confidence = confidence,
MatchedFrames = witness.NodeHashes.ToList(),
Discrepancies = confidence < 1.0
? [new WitnessDiscrepancy
{
Type = "PartialPathMatch",
Description = $"Node hash overlap: {confidence:P0}",
Severity = DiscrepancySeverity.Info
}]
: []
};
}
}
return CreateNoMatch(
"PathHash does not match",
expectedPathHash,
witness.PathHash ?? "(no hash)");
}
/// <summary>
/// Finds the best matching witness from a collection.
/// </summary>
public WitnessMatchResult FindBestMatch(IEnumerable<PathWitness> witnesses, string claimId)
{
ArgumentNullException.ThrowIfNull(witnesses);
WitnessMatchResult? bestMatch = null;
foreach (var witness in witnesses)
{
var match = Match(witness, claimId);
if (bestMatch == null || match.Confidence > bestMatch.Confidence)
{
bestMatch = match;
}
// Early exit on exact match
if (match.Confidence >= 1.0)
{
return match;
}
}
return bestMatch ?? WitnessMatchResult.NoMatch("No witnesses provided");
}
/// <summary>
/// Checks if a witness could potentially match a claim (fast pre-filter).
/// </summary>
public bool CouldMatch(PathWitness witness, string claimId)
{
if (!ClaimIdGenerator.IsValid(claimId))
return false;
var (artifactDigest, _) = ClaimIdGenerator.Parse(claimId);
return ArtifactDigestMatches(witness, artifactDigest);
}
private static bool ArtifactDigestMatches(PathWitness witness, string artifactDigest)
{
var witnessDigest = NormalizeHash(witness.Artifact.SbomDigest);
var expectedDigest = NormalizeHash(artifactDigest);
return witnessDigest == expectedDigest;
}
private static double ComputeNodeHashConfidence(IReadOnlyList<string> nodeHashes, string expectedPathHash)
{
// Simple heuristic: check if path hash appears in node hashes
// In a full implementation, this would compute actual overlap
var normalizedExpected = NormalizeHash(expectedPathHash);
// Check for substring match (simplified)
foreach (var nodeHash in nodeHashes)
{
var normalized = NormalizeHash(nodeHash);
if (normalized.Contains(normalizedExpected[..Math.Min(8, normalizedExpected.Length)], StringComparison.Ordinal))
{
return 0.9; // High confidence partial match
}
}
// Default partial confidence based on having node hashes
return nodeHashes.Count switch
{
>= 10 => 0.6,
>= 5 => 0.5,
>= 1 => 0.3,
_ => 0.0
};
}
private static string NormalizeHash(string hash)
{
if (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
hash = hash[7..];
if (hash.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
hash = hash[2..];
return hash.ToLowerInvariant();
}
private static WitnessMatchResult CreateNoMatch(string reason, string expected, string actual)
{
return new WitnessMatchResult
{
MatchStatus = WitnessMatchStatus.NoMatch,
Confidence = 0.0,
Discrepancies =
[
new WitnessDiscrepancy
{
Type = "Mismatch",
Description = reason,
Expected = expected,
Actual = actual,
Severity = DiscrepancySeverity.Error
}
]
};
}
}