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:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<artifact-digest>:<path-hash>".</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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:<artifact-digest>:<path-hash>".
|
||||
/// 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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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:<artifact-digest>:<path-hash>".
|
||||
/// </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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user