old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -269,6 +269,9 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
sb.AppendLine(" │ ├── manifest.json");
|
||||
sb.AppendLine(" │ ├── sbom.cdx.json");
|
||||
sb.AppendLine(" │ ├── reachability.json");
|
||||
sb.AppendLine(" │ ├── binary-diff.json # Binary diff evidence");
|
||||
sb.AppendLine(" │ ├── binary-diff.dsse.json # Signed binary diff (if attested)");
|
||||
sb.AppendLine(" │ ├── delta-proof.json # Semantic diff summary");
|
||||
sb.AppendLine(" │ ├── vex/");
|
||||
sb.AppendLine(" │ ├── attestations/");
|
||||
sb.AppendLine(" │ ├── policy/");
|
||||
@@ -359,6 +362,42 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Binary diff evidence - Sprint: SPRINT_20260112_009_SCANNER_binary_diff_bundle_export (BINDIFF-SCAN-002)
|
||||
if (evidence.BinaryDiff is not null)
|
||||
{
|
||||
await AddJsonFileAsync("binary-diff.json", evidence.BinaryDiff, streams, entries, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Add DSSE-signed binary diff if attestation refs are present
|
||||
if (evidence.BinaryDiff.AttestationRef is not null)
|
||||
{
|
||||
var dsseWrapper = new
|
||||
{
|
||||
payloadType = "application/vnd.stellaops.binary-diff+json",
|
||||
payload = evidence.BinaryDiff,
|
||||
attestationRef = evidence.BinaryDiff.AttestationRef
|
||||
};
|
||||
await AddJsonFileAsync("binary-diff.dsse.json", dsseWrapper, streams, entries, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Add delta proof summary for semantic fingerprint changes
|
||||
if (evidence.BinaryDiff.SemanticDiff is not null)
|
||||
{
|
||||
var deltaProof = new
|
||||
{
|
||||
previousFingerprint = evidence.BinaryDiff.SemanticDiff.PreviousFingerprint,
|
||||
currentFingerprint = evidence.BinaryDiff.SemanticDiff.CurrentFingerprint,
|
||||
similarityScore = evidence.BinaryDiff.SemanticDiff.SimilarityScore,
|
||||
semanticChanges = evidence.BinaryDiff.SemanticDiff.SemanticChanges,
|
||||
functionChangeCount = evidence.BinaryDiff.FunctionChangeCount,
|
||||
securityChangeCount = evidence.BinaryDiff.SecurityChangeCount
|
||||
};
|
||||
await AddJsonFileAsync("delta-proof.json", deltaProof, streams, entries, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Policy evidence
|
||||
if (evidence.Policy is not null)
|
||||
{
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PrAnnotationService.cs
|
||||
// Sprint: SPRINT_3700_0005_0001_witness_ui_cli
|
||||
// Tasks: PR-001, PR-002
|
||||
// Sprint: SPRINT_20260112_007_SCANNER_pr_mr_annotations (SCANNER-PR-002)
|
||||
// Tasks: PR-001, PR-002, SCANNER-PR-002
|
||||
// Description: Service for generating PR annotations with reachability state flips.
|
||||
// Updated: ASCII-only output, evidence anchors (attestation digest, witness id, policy verdict)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
@@ -114,6 +116,47 @@ public sealed record StateFlipSummary
|
||||
/// Individual state flips.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<StateFlip> Flips { get; init; }
|
||||
|
||||
// Sprint: SPRINT_20260112_007_SCANNER_pr_mr_annotations (SCANNER-PR-002)
|
||||
// Evidence anchor fields
|
||||
|
||||
/// <summary>
|
||||
/// DSSE attestation digest for the head scan.
|
||||
/// </summary>
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy verdict for the PR (pass/fail/warn).
|
||||
/// </summary>
|
||||
public string? PolicyVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy verdict reason code.
|
||||
/// </summary>
|
||||
public string? PolicyReasonCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verify command for reproducibility.
|
||||
/// </summary>
|
||||
public string? VerifyCommand { get; init; }
|
||||
}
|
||||
/// </summary>
|
||||
public required int NetChange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this PR should be blocked based on policy.
|
||||
/// </summary>
|
||||
public required bool ShouldBlockPr { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual state flips.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<StateFlip> Flips { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -321,29 +364,57 @@ public sealed class PrAnnotationService : IPrAnnotationService
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("## 🔍 Reachability Analysis");
|
||||
// Sprint: SPRINT_20260112_007_SCANNER_pr_mr_annotations (SCANNER-PR-002)
|
||||
// ASCII-only output with evidence anchors
|
||||
|
||||
// Header (ASCII-only)
|
||||
sb.AppendLine("## Reachability Analysis");
|
||||
sb.AppendLine();
|
||||
|
||||
// Status badge
|
||||
// Status badge (ASCII-only)
|
||||
if (summary.ShouldBlockPr)
|
||||
{
|
||||
sb.AppendLine("⛔ **Status: BLOCKING** - New reachable vulnerabilities detected");
|
||||
sb.AppendLine("[BLOCKING] **Status: BLOCKING** - New reachable vulnerabilities detected");
|
||||
}
|
||||
else if (summary.NewRiskCount > 0)
|
||||
{
|
||||
sb.AppendLine("⚠️ **Status: WARNING** - Reachability changes detected");
|
||||
sb.AppendLine("[WARNING] **Status: WARNING** - Reachability changes detected");
|
||||
}
|
||||
else if (summary.MitigatedCount > 0)
|
||||
{
|
||||
sb.AppendLine("✅ **Status: IMPROVED** - Vulnerabilities became unreachable");
|
||||
sb.AppendLine("[OK] **Status: IMPROVED** - Vulnerabilities became unreachable");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("✅ **Status: NO CHANGE** - No reachability changes");
|
||||
sb.AppendLine("[OK] **Status: NO CHANGE** - No reachability changes");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Evidence anchors section (SCANNER-PR-002)
|
||||
if (!string.IsNullOrEmpty(summary.AttestationDigest) ||
|
||||
!string.IsNullOrEmpty(summary.PolicyVerdict) ||
|
||||
!string.IsNullOrEmpty(summary.VerifyCommand))
|
||||
{
|
||||
sb.AppendLine("### Evidence");
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrEmpty(summary.AttestationDigest))
|
||||
{
|
||||
sb.AppendLine($"- **Attestation**: `{summary.AttestationDigest}`");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(summary.PolicyVerdict))
|
||||
{
|
||||
var reasonPart = !string.IsNullOrEmpty(summary.PolicyReasonCode)
|
||||
? $" ({summary.PolicyReasonCode})"
|
||||
: "";
|
||||
sb.AppendLine($"- **Policy Verdict**: {summary.PolicyVerdict}{reasonPart}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(summary.VerifyCommand))
|
||||
{
|
||||
sb.AppendLine($"- **Verify**: `{summary.VerifyCommand}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
sb.AppendLine("### Summary");
|
||||
sb.AppendLine($"| Metric | Count |");
|
||||
@@ -353,7 +424,7 @@ public sealed class PrAnnotationService : IPrAnnotationService
|
||||
sb.AppendLine($"| Net Change | {(summary.NetChange >= 0 ? "+" : "")}{summary.NetChange} |");
|
||||
sb.AppendLine();
|
||||
|
||||
// Flips table
|
||||
// Flips table (ASCII-only, deterministic ordering)
|
||||
if (summary.Flips.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### State Flips");
|
||||
@@ -361,22 +432,29 @@ public sealed class PrAnnotationService : IPrAnnotationService
|
||||
sb.AppendLine("| CVE | Package | Change | Confidence | Witness |");
|
||||
sb.AppendLine("|-----|---------|--------|------------|---------|");
|
||||
|
||||
foreach (var flip in summary.Flips.Take(20)) // Limit to 20 entries
|
||||
// Deterministic ordering: became reachable first, then by CVE ID
|
||||
var orderedFlips = summary.Flips
|
||||
.OrderByDescending(f => f.FlipType == StateFlipType.BecameReachable)
|
||||
.ThenBy(f => f.CveId, StringComparer.Ordinal)
|
||||
.Take(20);
|
||||
|
||||
foreach (var flip in orderedFlips)
|
||||
{
|
||||
var changeIcon = flip.FlipType switch
|
||||
// ASCII-only change indicators
|
||||
var changeText = flip.FlipType switch
|
||||
{
|
||||
StateFlipType.BecameReachable => "🔴 Became Reachable",
|
||||
StateFlipType.BecameUnreachable => "🟢 Became Unreachable",
|
||||
StateFlipType.TierIncreased => "🟡 Tier ↑",
|
||||
StateFlipType.TierDecreased => "🟢 Tier ↓",
|
||||
_ => "?"
|
||||
StateFlipType.BecameReachable => "[+] Became Reachable",
|
||||
StateFlipType.BecameUnreachable => "[-] Became Unreachable",
|
||||
StateFlipType.TierIncreased => "[^] Tier Increased",
|
||||
StateFlipType.TierDecreased => "[v] Tier Decreased",
|
||||
_ => "[?]"
|
||||
};
|
||||
|
||||
var witnessLink = !string.IsNullOrEmpty(flip.WitnessId)
|
||||
? $"[View](?witness={flip.WitnessId})"
|
||||
: "-";
|
||||
|
||||
sb.AppendLine($"| {flip.CveId} | `{TruncatePurl(flip.Purl)}` | {changeIcon} | {flip.NewTier} | {witnessLink} |");
|
||||
sb.AppendLine($"| {flip.CveId} | `{TruncatePurl(flip.Purl)}` | {changeText} | {flip.NewTier} | {witnessLink} |");
|
||||
}
|
||||
|
||||
if (summary.Flips.Count > 20)
|
||||
@@ -454,7 +532,15 @@ public sealed class PrAnnotationService : IPrAnnotationService
|
||||
{
|
||||
var annotations = new List<InlineAnnotation>();
|
||||
|
||||
foreach (var flip in flips.Where(f => !string.IsNullOrEmpty(f.FilePath) && f.LineNumber > 0))
|
||||
// Sprint: SPRINT_20260112_007_SCANNER_pr_mr_annotations (SCANNER-PR-002)
|
||||
// Deterministic ordering and ASCII-only output
|
||||
var orderedFlips = flips
|
||||
.Where(f => !string.IsNullOrEmpty(f.FilePath) && f.LineNumber > 0)
|
||||
.OrderByDescending(f => f.FlipType == StateFlipType.BecameReachable)
|
||||
.ThenBy(f => f.FilePath, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.LineNumber);
|
||||
|
||||
foreach (var flip in orderedFlips)
|
||||
{
|
||||
var level = flip.FlipType switch
|
||||
{
|
||||
@@ -465,17 +551,18 @@ public sealed class PrAnnotationService : IPrAnnotationService
|
||||
_ => AnnotationLevel.Notice
|
||||
};
|
||||
|
||||
// ASCII-only titles (no emoji)
|
||||
var title = flip.FlipType switch
|
||||
{
|
||||
StateFlipType.BecameReachable => $"🔴 {flip.CveId} is now reachable",
|
||||
StateFlipType.BecameUnreachable => $"🟢 {flip.CveId} is no longer reachable",
|
||||
StateFlipType.TierIncreased => $"🟡 {flip.CveId} reachability increased",
|
||||
StateFlipType.TierDecreased => $"🟢 {flip.CveId} reachability decreased",
|
||||
StateFlipType.BecameReachable => $"[+] {flip.CveId} is now reachable",
|
||||
StateFlipType.BecameUnreachable => $"[-] {flip.CveId} is no longer reachable",
|
||||
StateFlipType.TierIncreased => $"[^] {flip.CveId} reachability increased",
|
||||
StateFlipType.TierDecreased => $"[v] {flip.CveId} reachability decreased",
|
||||
_ => flip.CveId
|
||||
};
|
||||
|
||||
var message = $"Package: {flip.Purl}\n" +
|
||||
$"Confidence: {flip.PreviousTier ?? "N/A"} → {flip.NewTier}\n" +
|
||||
$"Confidence: {flip.PreviousTier ?? "N/A"} -> {flip.NewTier}\n" +
|
||||
(flip.Entrypoint != null ? $"Entrypoint: {flip.Entrypoint}\n" : "") +
|
||||
(flip.WitnessId != null ? $"Witness: {flip.WitnessId}" : "");
|
||||
|
||||
|
||||
@@ -2,6 +2,38 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known predicate types for path witness attestations.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-003)
|
||||
/// </summary>
|
||||
public static class WitnessPredicateTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical path witness predicate type URI.
|
||||
/// </summary>
|
||||
public const string PathWitnessCanonical = "https://stella.ops/predicates/path-witness/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Alias 1: stella.ops format for backward compatibility.
|
||||
/// </summary>
|
||||
public const string PathWitnessAlias1 = "stella.ops/pathWitness@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Alias 2: HTTPS URL format variant.
|
||||
/// </summary>
|
||||
public const string PathWitnessAlias2 = "https://stella.ops/pathWitness/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the predicate type is a recognized path witness type.
|
||||
/// </summary>
|
||||
public static bool IsPathWitnessType(string predicateType)
|
||||
{
|
||||
return predicateType == PathWitnessCanonical
|
||||
|| predicateType == PathWitnessAlias1
|
||||
|| predicateType == PathWitnessAlias2;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A DSSE-signable path witness documenting the call path from entrypoint to vulnerable sink.
|
||||
/// Conforms to stellaops.witness.v1 schema.
|
||||
@@ -67,6 +99,34 @@ public sealed record PathWitness
|
||||
/// </summary>
|
||||
[JsonPropertyName("observed_at")]
|
||||
public required DateTimeOffset ObservedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical path hash computed from node hashes along the path.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-003)
|
||||
/// </summary>
|
||||
[JsonPropertyName("path_hash")]
|
||||
public string? PathHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top-K node hashes along the path (deterministically ordered).
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-003)
|
||||
/// </summary>
|
||||
[JsonPropertyName("node_hashes")]
|
||||
public IReadOnlyList<string>? NodeHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence URIs for tracing back to source artifacts.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-003)
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_uris")]
|
||||
public IReadOnlyList<string>? EvidenceUris { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical predicate type URI for this witness.
|
||||
/// Default: https://stella.ops/predicates/path-witness/v1
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate_type")]
|
||||
public string PredicateType { get; init; } = WitnessPredicateTypes.PathWitnessCanonical;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -62,6 +62,13 @@ public sealed class PathWitnessBuilder : IPathWitnessBuilder
|
||||
var sinkNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.SymbolId == request.SinkSymbolId);
|
||||
var sinkSymbol = sinkNode?.Display ?? sinkNode?.Symbol?.Demangled ?? request.SinkSymbolId;
|
||||
|
||||
// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-003)
|
||||
// Compute node hashes and path hash for deterministic joining with runtime evidence
|
||||
var (nodeHashes, pathHash) = ComputePathHashes(request.ComponentPurl, path);
|
||||
|
||||
// Build evidence URIs for traceability
|
||||
var evidenceUris = BuildEvidenceUris(request);
|
||||
|
||||
// Build the witness
|
||||
var witness = new PathWitness
|
||||
{
|
||||
@@ -98,7 +105,12 @@ public sealed class PathWitnessBuilder : IPathWitnessBuilder
|
||||
AnalysisConfigDigest = request.AnalysisConfigDigest,
|
||||
BuildId = request.BuildId
|
||||
},
|
||||
ObservedAt = _timeProvider.GetUtcNow()
|
||||
ObservedAt = _timeProvider.GetUtcNow(),
|
||||
// PW-SCN-003: Add node hashes and path hash
|
||||
NodeHashes = nodeHashes,
|
||||
PathHash = pathHash,
|
||||
EvidenceUris = evidenceUris,
|
||||
PredicateType = WitnessPredicateTypes.PathWitnessCanonical
|
||||
};
|
||||
|
||||
// Compute witness ID from canonical content
|
||||
@@ -480,4 +492,108 @@ public sealed class PathWitnessBuilder : IPathWitnessBuilder
|
||||
|
||||
return $"{WitnessSchema.WitnessIdPrefix}{hash}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes node hashes and combined path hash for the witness path.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-003)
|
||||
/// </summary>
|
||||
/// <param name="componentPurl">Component PURL for hash computation.</param>
|
||||
/// <param name="path">Path steps from entrypoint to sink.</param>
|
||||
/// <returns>Tuple of (top-K node hashes, combined path hash).</returns>
|
||||
private static (IReadOnlyList<string> nodeHashes, string pathHash) ComputePathHashes(
|
||||
string componentPurl,
|
||||
IReadOnlyList<PathStep> path)
|
||||
{
|
||||
const int TopK = 10; // Return top-K node hashes
|
||||
|
||||
// Compute node hash for each step in the path
|
||||
var allNodeHashes = new List<string>();
|
||||
foreach (var step in path)
|
||||
{
|
||||
// Use SymbolId as the FQN for hash computation
|
||||
var nodeHash = ComputeNodeHash(componentPurl, step.SymbolId);
|
||||
allNodeHashes.Add(nodeHash);
|
||||
}
|
||||
|
||||
// Deduplicate and sort for deterministic ordering
|
||||
var uniqueHashes = allNodeHashes
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Select top-K hashes
|
||||
var topKHashes = uniqueHashes.Take(TopK).ToList();
|
||||
|
||||
// Compute combined path hash from all node hashes
|
||||
var pathHash = ComputeCombinedPathHash(allNodeHashes);
|
||||
|
||||
return (topKHashes, pathHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a canonical node hash from PURL and symbol FQN.
|
||||
/// Uses SHA-256 for compatibility with NodeHashRecipe in StellaOps.Reachability.Core.
|
||||
/// </summary>
|
||||
private static string ComputeNodeHash(string purl, string symbolFqn)
|
||||
{
|
||||
// Normalize inputs
|
||||
var normalizedPurl = purl?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
var normalizedSymbol = symbolFqn?.Trim() ?? string.Empty;
|
||||
|
||||
var input = $"{normalizedPurl}:{normalizedSymbol}";
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
|
||||
return "sha256:" + Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a combined path hash from ordered node hashes.
|
||||
/// </summary>
|
||||
private static string ComputeCombinedPathHash(IReadOnlyList<string> nodeHashes)
|
||||
{
|
||||
// Extract hex parts and concatenate in order
|
||||
var hexParts = nodeHashes
|
||||
.Select(h => h.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? h[7..] : h)
|
||||
.ToList();
|
||||
|
||||
var combined = string.Join(":", hexParts);
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
||||
|
||||
return "path:sha256:" + Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds evidence URIs for traceability.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-003)
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> BuildEvidenceUris(PathWitnessRequest request)
|
||||
{
|
||||
var uris = new List<string>();
|
||||
|
||||
// Add callgraph evidence URI
|
||||
if (!string.IsNullOrWhiteSpace(request.CallgraphDigest))
|
||||
{
|
||||
uris.Add($"evidence:callgraph:{request.CallgraphDigest}");
|
||||
}
|
||||
|
||||
// Add SBOM evidence URI
|
||||
if (!string.IsNullOrWhiteSpace(request.SbomDigest))
|
||||
{
|
||||
uris.Add($"evidence:sbom:{request.SbomDigest}");
|
||||
}
|
||||
|
||||
// Add surface evidence URI
|
||||
if (!string.IsNullOrWhiteSpace(request.SurfaceDigest))
|
||||
{
|
||||
uris.Add($"evidence:surface:{request.SurfaceDigest}");
|
||||
}
|
||||
|
||||
// Add build evidence URI
|
||||
if (!string.IsNullOrWhiteSpace(request.BuildId))
|
||||
{
|
||||
uris.Add($"evidence:build:{request.BuildId}");
|
||||
}
|
||||
|
||||
return uris;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,4 +460,114 @@ public class SarifExportServiceTests
|
||||
result.Properties.Should().ContainKey("github/alertCategory");
|
||||
result.Properties!["github/alertCategory"].Should().Be("security");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithNodeHash_IncludesHashMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var findings = new[]
|
||||
{
|
||||
new FindingInput
|
||||
{
|
||||
Type = FindingType.Vulnerability,
|
||||
Title = "Test Vulnerability",
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
Severity = Severity.High,
|
||||
NodeHash = "sha256:abc123def456",
|
||||
PathHash = "path:sha256:789xyz",
|
||||
PathNodeHashes = new[] { "sha256:node1", "sha256:node2", "sha256:node3" },
|
||||
Reachability = ReachabilityStatus.StaticReachable
|
||||
}
|
||||
};
|
||||
|
||||
var options = new SarifExportOptions
|
||||
{
|
||||
ToolVersion = "1.0.0",
|
||||
IncludeReachability = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var result = log.Runs[0].Results[0];
|
||||
result.Properties.Should().ContainKey("stellaops/node/hash");
|
||||
result.Properties!["stellaops/node/hash"].Should().Be("sha256:abc123def456");
|
||||
result.Properties.Should().ContainKey("stellaops/path/hash");
|
||||
result.Properties!["stellaops/path/hash"].Should().Be("path:sha256:789xyz");
|
||||
result.Properties.Should().ContainKey("stellaops/path/nodeHashes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithFunctionSignature_IncludesFunctionMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var findings = new[]
|
||||
{
|
||||
new FindingInput
|
||||
{
|
||||
Type = FindingType.Vulnerability,
|
||||
Title = "Test Vulnerability",
|
||||
VulnerabilityId = "CVE-2026-5678",
|
||||
Severity = Severity.Critical,
|
||||
FunctionSignature = "void ProcessInput(string data)",
|
||||
FunctionName = "ProcessInput",
|
||||
FunctionNamespace = "MyApp.Controllers.UserController"
|
||||
}
|
||||
};
|
||||
|
||||
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
|
||||
|
||||
// Act
|
||||
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var result = log.Runs[0].Results[0];
|
||||
result.Properties.Should().ContainKey("stellaops/function/signature");
|
||||
result.Properties!["stellaops/function/signature"].Should().Be("void ProcessInput(string data)");
|
||||
result.Properties.Should().ContainKey("stellaops/function/name");
|
||||
result.Properties!["stellaops/function/name"].Should().Be("ProcessInput");
|
||||
result.Properties.Should().ContainKey("stellaops/function/namespace");
|
||||
result.Properties!["stellaops/function/namespace"].Should().Be("MyApp.Controllers.UserController");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExportAsync_NodeHashWithoutReachabilityFlag_ExcludesHashes()
|
||||
{
|
||||
// Arrange
|
||||
var findings = new[]
|
||||
{
|
||||
new FindingInput
|
||||
{
|
||||
Type = FindingType.Vulnerability,
|
||||
Title = "Test Vulnerability",
|
||||
Severity = Severity.Medium,
|
||||
NodeHash = "sha256:abc123def456",
|
||||
PathHash = "path:sha256:789xyz"
|
||||
}
|
||||
};
|
||||
|
||||
var options = new SarifExportOptions
|
||||
{
|
||||
ToolVersion = "1.0.0",
|
||||
IncludeReachability = false // Hashes should only appear with reachability enabled
|
||||
};
|
||||
|
||||
// Act
|
||||
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var result = log.Runs[0].Results[0];
|
||||
result.Properties.Should().NotContainKey("stellaops/node/hash");
|
||||
result.Properties.Should().NotContainKey("stellaops/path/hash");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,42 @@ public sealed record FindingInput
|
||||
/// Gets custom properties to include.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object>? Properties { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canonical node hash for the finding location.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
public string? NodeHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the combined path hash if this finding has a reachability path.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
public string? PathHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top-K node hashes along the reachability path.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? PathNodeHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function signature at the finding location.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
public string? FunctionSignature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fully qualified function name.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
public string? FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the namespace or module of the function.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
/// </summary>
|
||||
public string? FunctionNamespace { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -315,6 +315,43 @@ public sealed class SarifExportService : ISarifExportService
|
||||
props["stellaops/attestation"] = finding.AttestationDigests;
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
// Node hash and path hash for reachability evidence joining
|
||||
if (options.IncludeReachability)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(finding.NodeHash))
|
||||
{
|
||||
props["stellaops/node/hash"] = finding.NodeHash;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(finding.PathHash))
|
||||
{
|
||||
props["stellaops/path/hash"] = finding.PathHash;
|
||||
}
|
||||
|
||||
if (finding.PathNodeHashes?.Count > 0)
|
||||
{
|
||||
props["stellaops/path/nodeHashes"] = finding.PathNodeHashes;
|
||||
}
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-004)
|
||||
// Function signature metadata
|
||||
if (!string.IsNullOrEmpty(finding.FunctionSignature))
|
||||
{
|
||||
props["stellaops/function/signature"] = finding.FunctionSignature;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(finding.FunctionName))
|
||||
{
|
||||
props["stellaops/function/name"] = finding.FunctionName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(finding.FunctionNamespace))
|
||||
{
|
||||
props["stellaops/function/namespace"] = finding.FunctionNamespace;
|
||||
}
|
||||
|
||||
// Category
|
||||
if (!string.IsNullOrEmpty(options.Category))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260112_005_SCANNER_epss_reanalysis_events (SCAN-EPSS-004)
|
||||
// Task: Tests for EPSS event payload determinism and idempotency keys
|
||||
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Epss;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EPSS change event determinism and idempotency.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class EpssChangeEventDeterminismTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateOnly ModelDate = new(2026, 1, 14);
|
||||
private static readonly DateOnly PreviousModelDate = new(2026, 1, 13);
|
||||
|
||||
[Fact]
|
||||
public void Create_SameInputs_ProducesSameEventId()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "test-tenant";
|
||||
var cveId = "CVE-2024-1234";
|
||||
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.75,
|
||||
Percentile = 0.95,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = cveId,
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var event1 = EpssChangeEventFactory.Create(
|
||||
tenant, cveId, null, current, FixedTime);
|
||||
|
||||
var event2 = EpssChangeEventFactory.Create(
|
||||
tenant, cveId, null, current, FixedTime);
|
||||
|
||||
// Assert - same inputs must produce same event ID
|
||||
Assert.Equal(event1.EventId, event2.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_DifferentScore_ProducesDifferentEventId()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "test-tenant";
|
||||
var cveId = "CVE-2024-1234";
|
||||
|
||||
var current1 = new EpssEvidence
|
||||
{
|
||||
Score = 0.75,
|
||||
Percentile = 0.95,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = cveId,
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
var current2 = new EpssEvidence
|
||||
{
|
||||
Score = 0.80,
|
||||
Percentile = 0.95,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = cveId,
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var event1 = EpssChangeEventFactory.Create(
|
||||
tenant, cveId, null, current1, FixedTime);
|
||||
|
||||
var event2 = EpssChangeEventFactory.Create(
|
||||
tenant, cveId, null, current2, FixedTime);
|
||||
|
||||
// Assert - different scores must produce different event IDs
|
||||
Assert.NotEqual(event1.EventId, event2.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_DifferentModelDate_ProducesDifferentEventId()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "test-tenant";
|
||||
var cveId = "CVE-2024-1234";
|
||||
|
||||
var current1 = new EpssEvidence
|
||||
{
|
||||
Score = 0.75,
|
||||
Percentile = 0.95,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = cveId,
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
var current2 = new EpssEvidence
|
||||
{
|
||||
Score = 0.75,
|
||||
Percentile = 0.95,
|
||||
ModelDate = PreviousModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = cveId,
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var event1 = EpssChangeEventFactory.Create(
|
||||
tenant, cveId, null, current1, FixedTime);
|
||||
|
||||
var event2 = EpssChangeEventFactory.Create(
|
||||
tenant, cveId, null, current2, FixedTime);
|
||||
|
||||
// Assert - different model dates must produce different event IDs
|
||||
Assert.NotEqual(event1.EventId, event2.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_DifferentCveId_ProducesDifferentEventId()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "test-tenant";
|
||||
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.75,
|
||||
Percentile = 0.95,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var event1 = EpssChangeEventFactory.Create(
|
||||
tenant, "CVE-2024-1234", null, current, FixedTime);
|
||||
|
||||
var event2 = EpssChangeEventFactory.Create(
|
||||
tenant, "CVE-2024-5678", null, current with { CveId = "CVE-2024-5678" }, FixedTime);
|
||||
|
||||
// Assert - different CVE IDs must produce different event IDs
|
||||
Assert.NotEqual(event1.EventId, event2.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_EventIdFormat_IsCorrect()
|
||||
{
|
||||
// Arrange
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.75,
|
||||
Percentile = 0.95,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var evt = EpssChangeEventFactory.Create(
|
||||
"test-tenant", "CVE-2024-1234", null, current, FixedTime);
|
||||
|
||||
// Assert - event ID should follow epss-evt-{16-char-hex} format
|
||||
Assert.StartsWith("epss-evt-", evt.EventId);
|
||||
Assert.Equal(25, evt.EventId.Length); // "epss-evt-" (9) + 16 hex chars
|
||||
Assert.Matches("^epss-evt-[0-9a-f]{16}$", evt.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_DifferentTimestamp_ProducesSameEventId()
|
||||
{
|
||||
// Arrange - timestamps should NOT affect event ID (idempotency)
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.75,
|
||||
Percentile = 0.95,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var event1 = EpssChangeEventFactory.Create(
|
||||
"test-tenant", "CVE-2024-1234", null, current, FixedTime);
|
||||
|
||||
var event2 = EpssChangeEventFactory.Create(
|
||||
"test-tenant", "CVE-2024-1234", null, current, FixedTime.AddHours(1));
|
||||
|
||||
// Assert - event ID should be idempotent based on CVE + model date + score
|
||||
Assert.Equal(event1.EventId, event2.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ScoreExceedsThreshold_SetsExceedsThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new EpssEvidence
|
||||
{
|
||||
Score = 0.30,
|
||||
Percentile = 0.70,
|
||||
ModelDate = PreviousModelDate,
|
||||
CapturedAt = FixedTime.AddDays(-1),
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.55, // Delta = 0.25 > 0.2 threshold
|
||||
Percentile = 0.85,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var evt = EpssChangeEventFactory.Create(
|
||||
"test-tenant", "CVE-2024-1234", previous, current, FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(evt.ExceedsThreshold);
|
||||
Assert.Equal(0.2, evt.ThresholdExceeded);
|
||||
Assert.Equal(EpssEventTypes.DeltaExceeded, evt.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ScoreBelowThreshold_DoesNotExceedThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new EpssEvidence
|
||||
{
|
||||
Score = 0.30,
|
||||
Percentile = 0.70,
|
||||
ModelDate = PreviousModelDate,
|
||||
CapturedAt = FixedTime.AddDays(-1),
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.35, // Delta = 0.05 < 0.2 threshold
|
||||
Percentile = 0.72,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var evt = EpssChangeEventFactory.Create(
|
||||
"test-tenant", "CVE-2024-1234", previous, current, FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.False(evt.ExceedsThreshold);
|
||||
Assert.Null(evt.ThresholdExceeded);
|
||||
Assert.Equal(EpssEventTypes.Updated, evt.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_NewCve_SetsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.40,
|
||||
Percentile = 0.80,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act - no previous means new CVE
|
||||
var evt = EpssChangeEventFactory.Create(
|
||||
"test-tenant", "CVE-2024-1234", null, current, FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EpssEventTypes.NewCve, evt.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_HighPriorityScore_ExceedsThreshold()
|
||||
{
|
||||
// Arrange - score above 0.7 threshold triggers regardless of delta
|
||||
var previous = new EpssEvidence
|
||||
{
|
||||
Score = 0.65,
|
||||
Percentile = 0.90,
|
||||
ModelDate = PreviousModelDate,
|
||||
CapturedAt = FixedTime.AddDays(-1),
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.72, // Delta = 0.07 < 0.2, but score > 0.7
|
||||
Percentile = 0.92,
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var evt = EpssChangeEventFactory.Create(
|
||||
"test-tenant", "CVE-2024-1234", previous, current, FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(evt.ExceedsThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_BandChange_ExceedsThreshold()
|
||||
{
|
||||
// Arrange - band change triggers reanalysis
|
||||
var previous = new EpssEvidence
|
||||
{
|
||||
Score = 0.45,
|
||||
Percentile = 0.74, // medium band (< 0.75)
|
||||
ModelDate = PreviousModelDate,
|
||||
CapturedAt = FixedTime.AddDays(-1),
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
var current = new EpssEvidence
|
||||
{
|
||||
Score = 0.48, // Delta = 0.03 < 0.2
|
||||
Percentile = 0.76, // high band (>= 0.75)
|
||||
ModelDate = ModelDate,
|
||||
CapturedAt = FixedTime,
|
||||
CveId = "CVE-2024-1234",
|
||||
Source = "first.ai"
|
||||
};
|
||||
|
||||
// Act
|
||||
var evt = EpssChangeEventFactory.Create(
|
||||
"test-tenant", "CVE-2024-1234", previous, current, FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(evt.BandChanged);
|
||||
Assert.True(evt.ExceedsThreshold);
|
||||
Assert.Equal("low", evt.PreviousBand);
|
||||
Assert.Equal("medium", evt.NewBand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBatch_ProducesDeterministicBatchId()
|
||||
{
|
||||
// Arrange
|
||||
var changes = CreateTestChanges();
|
||||
|
||||
// Act
|
||||
var batch1 = EpssChangeEventFactory.CreateBatch(
|
||||
"test-tenant", ModelDate, changes, FixedTime);
|
||||
|
||||
var batch2 = EpssChangeEventFactory.CreateBatch(
|
||||
"test-tenant", ModelDate, changes, FixedTime);
|
||||
|
||||
// Assert - same inputs produce same batch ID
|
||||
Assert.Equal(batch1.BatchId, batch2.BatchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBatch_DifferentTenant_ProducesDifferentBatchId()
|
||||
{
|
||||
// Arrange
|
||||
var changes = CreateTestChanges();
|
||||
|
||||
// Act
|
||||
var batch1 = EpssChangeEventFactory.CreateBatch(
|
||||
"tenant-a", ModelDate, changes, FixedTime);
|
||||
|
||||
var batch2 = EpssChangeEventFactory.CreateBatch(
|
||||
"tenant-b", ModelDate, changes, FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(batch1.BatchId, batch2.BatchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBatch_OnlyIncludesThresholdChanges()
|
||||
{
|
||||
// Arrange - mix of threshold and non-threshold changes
|
||||
var allChanges = new[]
|
||||
{
|
||||
CreateChangeEvent("CVE-2024-0001", exceedsThreshold: true),
|
||||
CreateChangeEvent("CVE-2024-0002", exceedsThreshold: false),
|
||||
CreateChangeEvent("CVE-2024-0003", exceedsThreshold: true),
|
||||
CreateChangeEvent("CVE-2024-0004", exceedsThreshold: false),
|
||||
};
|
||||
|
||||
// Act
|
||||
var batch = EpssChangeEventFactory.CreateBatch(
|
||||
"test-tenant", ModelDate, allChanges, FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, batch.TotalProcessed);
|
||||
Assert.Equal(2, batch.ChangesExceedingThreshold);
|
||||
Assert.Equal(2, batch.Changes.Length);
|
||||
Assert.All(batch.Changes, c => Assert.True(c.ExceedsThreshold));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBatch_ChangesOrderedByCveId()
|
||||
{
|
||||
// Arrange - unordered input
|
||||
var allChanges = new[]
|
||||
{
|
||||
CreateChangeEvent("CVE-2024-0003", exceedsThreshold: true),
|
||||
CreateChangeEvent("CVE-2024-0001", exceedsThreshold: true),
|
||||
CreateChangeEvent("CVE-2024-0002", exceedsThreshold: true),
|
||||
};
|
||||
|
||||
// Act
|
||||
var batch = EpssChangeEventFactory.CreateBatch(
|
||||
"test-tenant", ModelDate, allChanges, FixedTime);
|
||||
|
||||
// Assert - changes should be ordered by CVE ID
|
||||
Assert.Equal("CVE-2024-0001", batch.Changes[0].CveId);
|
||||
Assert.Equal("CVE-2024-0002", batch.Changes[1].CveId);
|
||||
Assert.Equal("CVE-2024-0003", batch.Changes[2].CveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBatch_BatchIdFormat_IsCorrect()
|
||||
{
|
||||
// Arrange
|
||||
var changes = CreateTestChanges();
|
||||
|
||||
// Act
|
||||
var batch = EpssChangeEventFactory.CreateBatch(
|
||||
"test-tenant", ModelDate, changes, FixedTime);
|
||||
|
||||
// Assert - batch ID should follow epss-batch-{16-char-hex} format
|
||||
Assert.StartsWith("epss-batch-", batch.BatchId);
|
||||
Assert.Matches("^epss-batch-[0-9a-f]{16}$", batch.BatchId);
|
||||
}
|
||||
|
||||
private static IEnumerable<EpssChangeEvent> CreateTestChanges()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
CreateChangeEvent("CVE-2024-0001", exceedsThreshold: true),
|
||||
CreateChangeEvent("CVE-2024-0002", exceedsThreshold: true),
|
||||
};
|
||||
}
|
||||
|
||||
private static EpssChangeEvent CreateChangeEvent(string cveId, bool exceedsThreshold)
|
||||
{
|
||||
return new EpssChangeEvent
|
||||
{
|
||||
EventId = $"epss-evt-{cveId.GetHashCode():x16}",
|
||||
EventType = exceedsThreshold ? EpssEventTypes.DeltaExceeded : EpssEventTypes.Updated,
|
||||
Tenant = "test-tenant",
|
||||
CveId = cveId,
|
||||
PreviousScore = 0.30,
|
||||
NewScore = exceedsThreshold ? 0.55 : 0.32,
|
||||
ScoreDelta = exceedsThreshold ? 0.25 : 0.02,
|
||||
PreviousPercentile = 0.70,
|
||||
NewPercentile = exceedsThreshold ? 0.85 : 0.71,
|
||||
PercentileDelta = exceedsThreshold ? 0.15 : 0.01,
|
||||
PreviousBand = "low",
|
||||
NewBand = exceedsThreshold ? "medium" : "low",
|
||||
BandChanged = exceedsThreshold,
|
||||
ModelDate = ModelDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
PreviousModelDate = PreviousModelDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
ExceedsThreshold = exceedsThreshold,
|
||||
ThresholdExceeded = exceedsThreshold ? 0.2 : null,
|
||||
Source = "first.ai",
|
||||
CreatedAtUtc = FixedTime,
|
||||
TraceId = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -542,5 +542,200 @@ public class PathWitnessBuilderTests
|
||||
Assert.Null(w.Path[1].File);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-005)
|
||||
/// Verify witness outputs include node hashes and path hash.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_IncludesNodeHashesAndPathHash()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
|
||||
|
||||
var request = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3",
|
||||
VulnId = "CVE-2024-12345",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=12.0.3",
|
||||
EntrypointSymbolId = "sym:entry1",
|
||||
EntrypointKind = "http",
|
||||
EntrypointName = "GET /api/test",
|
||||
SinkSymbolId = "sym:sink1",
|
||||
SinkType = "deserialization",
|
||||
CallGraph = graph,
|
||||
CallgraphDigest = "blake3:abc123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request, TestCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.NodeHashes);
|
||||
Assert.NotEmpty(result.NodeHashes);
|
||||
Assert.All(result.NodeHashes, h => Assert.StartsWith("sha256:", h));
|
||||
Assert.NotNull(result.PathHash);
|
||||
Assert.StartsWith("path:sha256:", result.PathHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-005)
|
||||
/// Verify witness outputs include evidence URIs.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_IncludesEvidenceUris()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
|
||||
|
||||
var request = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-12345",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
EntrypointSymbolId = "sym:entry1",
|
||||
EntrypointKind = "http",
|
||||
EntrypointName = "GET /api/test",
|
||||
SinkSymbolId = "sym:sink1",
|
||||
SinkType = "deserialization",
|
||||
CallGraph = graph,
|
||||
CallgraphDigest = "blake3:graph456",
|
||||
SurfaceDigest = "sha256:surface789",
|
||||
BuildId = "build-001"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request, TestCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.EvidenceUris);
|
||||
Assert.Contains(result.EvidenceUris, u => u.StartsWith("evidence:callgraph:"));
|
||||
Assert.Contains(result.EvidenceUris, u => u.StartsWith("evidence:sbom:"));
|
||||
Assert.Contains(result.EvidenceUris, u => u.StartsWith("evidence:surface:"));
|
||||
Assert.Contains(result.EvidenceUris, u => u.StartsWith("evidence:build:"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-005)
|
||||
/// Verify witness uses canonical predicate type.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_UsesCanonicalPredicateType()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
|
||||
|
||||
var request = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-12345",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
EntrypointSymbolId = "sym:entry1",
|
||||
EntrypointKind = "http",
|
||||
EntrypointName = "GET /api/test",
|
||||
SinkSymbolId = "sym:sink1",
|
||||
SinkType = "deserialization",
|
||||
CallGraph = graph,
|
||||
CallgraphDigest = "blake3:graph456"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request, TestCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(WitnessPredicateTypes.PathWitnessCanonical, result.PredicateType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-005)
|
||||
/// Verify DSSE payload determinism - same inputs produce same hashes.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_ProducesDeterministicPathHash()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
|
||||
|
||||
var request = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-12345",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
EntrypointSymbolId = "sym:entry1",
|
||||
EntrypointKind = "http",
|
||||
EntrypointName = "GET /api/test",
|
||||
SinkSymbolId = "sym:sink1",
|
||||
SinkType = "deserialization",
|
||||
CallGraph = graph,
|
||||
CallgraphDigest = "blake3:graph456"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await builder.BuildAsync(request, TestCancellationToken);
|
||||
var result2 = await builder.BuildAsync(request, TestCancellationToken);
|
||||
|
||||
// Assert - same inputs should produce identical hashes
|
||||
Assert.NotNull(result1);
|
||||
Assert.NotNull(result2);
|
||||
Assert.Equal(result1.PathHash, result2.PathHash);
|
||||
Assert.Equal(result1.NodeHashes, result2.NodeHashes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-005)
|
||||
/// Verify node hashes are deterministically sorted.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_NodeHashesAreSorted()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
|
||||
|
||||
var request = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-12345",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
EntrypointSymbolId = "sym:entry1",
|
||||
EntrypointKind = "http",
|
||||
EntrypointName = "GET /api/test",
|
||||
SinkSymbolId = "sym:sink1",
|
||||
SinkType = "deserialization",
|
||||
CallGraph = graph,
|
||||
CallgraphDigest = "blake3:graph456"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request, TestCancellationToken);
|
||||
|
||||
// Assert - node hashes should be in sorted order
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.NodeHashes);
|
||||
var sorted = result.NodeHashes.OrderBy(h => h, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(sorted, result.NodeHashes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// <copyright file="EvidenceBundleExporterBinaryDiffTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_009_SCANNER_binary_diff_bundle_export (BINDIFF-SCAN-004)
|
||||
// </copyright>
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for binary diff evidence export in EvidenceBundleExporter.
|
||||
/// Sprint: SPRINT_20260112_009_SCANNER_binary_diff_bundle_export (BINDIFF-SCAN-004)
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class EvidenceBundleExporterBinaryDiffTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
private readonly EvidenceBundleExporter _exporter;
|
||||
|
||||
public EvidenceBundleExporterBinaryDiffTests()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(FixedTime);
|
||||
_exporter = new EvidenceBundleExporter(timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithBinaryDiff_IncludesBinaryDiffJson()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateEvidenceWithBinaryDiff();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(evidence, EvidenceExportFormat.Zip);
|
||||
|
||||
// Assert
|
||||
using var archive = new ZipArchive(result.Stream, ZipArchiveMode.Read);
|
||||
var binaryDiffEntry = archive.Entries.FirstOrDefault(e => e.Name == "binary-diff.json");
|
||||
Assert.NotNull(binaryDiffEntry);
|
||||
|
||||
using var reader = new StreamReader(binaryDiffEntry.Open());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
Assert.Contains("semantic", content.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithBinaryDiffAttestation_IncludesDsseJson()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateEvidenceWithBinaryDiffAndAttestation();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(evidence, EvidenceExportFormat.Zip);
|
||||
|
||||
// Assert
|
||||
using var archive = new ZipArchive(result.Stream, ZipArchiveMode.Read);
|
||||
var dsseEntry = archive.Entries.FirstOrDefault(e => e.Name == "binary-diff.dsse.json");
|
||||
Assert.NotNull(dsseEntry);
|
||||
|
||||
using var reader = new StreamReader(dsseEntry.Open());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
Assert.Contains("payloadType", content);
|
||||
Assert.Contains("attestationRef", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithSemanticDiff_IncludesDeltaProofJson()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateEvidenceWithSemanticDiff();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(evidence, EvidenceExportFormat.Zip);
|
||||
|
||||
// Assert
|
||||
using var archive = new ZipArchive(result.Stream, ZipArchiveMode.Read);
|
||||
var deltaProofEntry = archive.Entries.FirstOrDefault(e => e.Name == "delta-proof.json");
|
||||
Assert.NotNull(deltaProofEntry);
|
||||
|
||||
using var reader = new StreamReader(deltaProofEntry.Open());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
Assert.Contains("previousFingerprint", content);
|
||||
Assert.Contains("currentFingerprint", content);
|
||||
Assert.Contains("similarityScore", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithoutBinaryDiff_DoesNotIncludeBinaryDiffFiles()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateMinimalEvidence();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(evidence, EvidenceExportFormat.Zip);
|
||||
|
||||
// Assert
|
||||
using var archive = new ZipArchive(result.Stream, ZipArchiveMode.Read);
|
||||
Assert.DoesNotContain(archive.Entries, e => e.Name == "binary-diff.json");
|
||||
Assert.DoesNotContain(archive.Entries, e => e.Name == "binary-diff.dsse.json");
|
||||
Assert.DoesNotContain(archive.Entries, e => e.Name == "delta-proof.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_BinaryDiffFilesInManifest()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateEvidenceWithBinaryDiffAndAttestation();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(evidence, EvidenceExportFormat.Zip);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Manifest);
|
||||
var filePaths = result.Manifest.Files.Select(f => f.Path).ToList();
|
||||
Assert.Contains("binary-diff.json", filePaths);
|
||||
Assert.Contains("binary-diff.dsse.json", filePaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_BinaryDiffFileHashes_AreDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateEvidenceWithBinaryDiff();
|
||||
|
||||
// Act
|
||||
var result1 = await _exporter.ExportAsync(evidence, EvidenceExportFormat.Zip);
|
||||
var result2 = await _exporter.ExportAsync(evidence, EvidenceExportFormat.Zip);
|
||||
|
||||
// Assert - Same input should produce same file hashes
|
||||
var hash1 = result1.Manifest!.Files.First(f => f.Path == "binary-diff.json").Sha256;
|
||||
var hash2 = result2.Manifest!.Files.First(f => f.Path == "binary-diff.json").Sha256;
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_BinaryDiffOrdering_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateEvidenceWithBinaryDiffAndAttestation();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(evidence, EvidenceExportFormat.Zip);
|
||||
|
||||
// Assert - Files should appear in consistent order
|
||||
using var archive = new ZipArchive(result.Stream, ZipArchiveMode.Read);
|
||||
var fileNames = archive.Entries.Select(e => e.Name).ToList();
|
||||
|
||||
// binary-diff.json should appear before binary-diff.dsse.json
|
||||
var binaryDiffIndex = fileNames.IndexOf("binary-diff.json");
|
||||
var dsseIndex = fileNames.IndexOf("binary-diff.dsse.json");
|
||||
Assert.True(binaryDiffIndex < dsseIndex,
|
||||
"binary-diff.json should appear before binary-diff.dsse.json for deterministic ordering");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_TarGzFormat_IncludesBinaryDiffFiles()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateEvidenceWithBinaryDiff();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(evidence, EvidenceExportFormat.TarGz);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/gzip", result.ContentType);
|
||||
Assert.EndsWith(".tar.gz", result.FileName);
|
||||
Assert.NotNull(result.Manifest);
|
||||
Assert.Contains(result.Manifest.Files, f => f.Path == "binary-diff.json");
|
||||
}
|
||||
|
||||
private static UnifiedEvidenceResponseDto CreateMinimalEvidence()
|
||||
{
|
||||
return new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = "finding-001",
|
||||
CveId = "CVE-2026-1234",
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.21",
|
||||
CacheKey = "cache-key-001",
|
||||
Manifests = new ManifestsDto
|
||||
{
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
ManifestHash = "sha256:manifest",
|
||||
FeedSnapshotHash = "sha256:feed",
|
||||
PolicyHash = "sha256:policy"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static UnifiedEvidenceResponseDto CreateEvidenceWithBinaryDiff()
|
||||
{
|
||||
var evidence = CreateMinimalEvidence();
|
||||
evidence.BinaryDiff = new BinaryDiffEvidenceDto
|
||||
{
|
||||
Status = "available",
|
||||
DiffType = "semantic",
|
||||
PreviousBinaryDigest = "sha256:old123",
|
||||
CurrentBinaryDigest = "sha256:new456",
|
||||
SimilarityScore = 0.95,
|
||||
FunctionChangeCount = 3,
|
||||
SecurityChangeCount = 1
|
||||
};
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static UnifiedEvidenceResponseDto CreateEvidenceWithBinaryDiffAndAttestation()
|
||||
{
|
||||
var evidence = CreateEvidenceWithBinaryDiff();
|
||||
evidence.BinaryDiff!.AttestationRef = new AttestationRefDto
|
||||
{
|
||||
Id = "attest-12345",
|
||||
RekorLogIndex = 123456789,
|
||||
BundleDigest = "sha256:bundle123"
|
||||
};
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static UnifiedEvidenceResponseDto CreateEvidenceWithSemanticDiff()
|
||||
{
|
||||
var evidence = CreateEvidenceWithBinaryDiff();
|
||||
evidence.BinaryDiff!.SemanticDiff = new BinarySemanticDiffDto
|
||||
{
|
||||
PreviousFingerprint = "fp:abc123",
|
||||
CurrentFingerprint = "fp:def456",
|
||||
SimilarityScore = 0.92,
|
||||
SemanticChanges = new List<string> { "control_flow_modified", "data_flow_changed" }
|
||||
};
|
||||
return evidence;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PrAnnotationServiceTests.cs
|
||||
// Sprint: SPRINT_20260112_007_SCANNER_pr_mr_annotations (SCANNER-PR-004)
|
||||
// Description: Tests for PR annotation service with ASCII-only output and evidence anchors.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class PrAnnotationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly PrAnnotationService _service;
|
||||
|
||||
public PrAnnotationServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_service = new PrAnnotationService(
|
||||
new FakeReachabilityQueryService(),
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_NoFlips_ReturnsAsciiOnlyOutput()
|
||||
{
|
||||
// Arrange
|
||||
var summary = CreateSummary(newRiskCount: 0, mitigatedCount: 0, flips: []);
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("\u2705", comment); // No checkmark emoji
|
||||
Assert.DoesNotContain("\u26d4", comment); // No stop sign emoji
|
||||
Assert.DoesNotContain("\u26a0", comment); // No warning sign emoji
|
||||
Assert.DoesNotContain("\u2192", comment); // No arrow
|
||||
Assert.Contains("[OK]", comment);
|
||||
Assert.Contains("NO CHANGE", comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_WithNewRisks_ReturnsBlockingStatus()
|
||||
{
|
||||
// Arrange
|
||||
var flips = new List<StateFlip>
|
||||
{
|
||||
new StateFlip
|
||||
{
|
||||
FlipType = StateFlipType.BecameReachable,
|
||||
CveId = "CVE-2026-0001",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
NewTier = "confirmed",
|
||||
WitnessId = "witness-123"
|
||||
}
|
||||
};
|
||||
var summary = CreateSummary(newRiskCount: 1, mitigatedCount: 0, flips: flips, shouldBlock: true);
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[BLOCKING]", comment);
|
||||
Assert.Contains("[+] Became Reachable", comment);
|
||||
Assert.DoesNotContain("\ud83d\udd34", comment); // No red circle emoji
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_WithMitigatedRisks_ReturnsImprovedStatus()
|
||||
{
|
||||
// Arrange
|
||||
var flips = new List<StateFlip>
|
||||
{
|
||||
new StateFlip
|
||||
{
|
||||
FlipType = StateFlipType.BecameUnreachable,
|
||||
CveId = "CVE-2026-0002",
|
||||
Purl = "pkg:npm/express@4.18.0",
|
||||
PreviousTier = "likely",
|
||||
NewTier = "unreachable"
|
||||
}
|
||||
};
|
||||
var summary = CreateSummary(newRiskCount: 0, mitigatedCount: 1, flips: flips);
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[OK]", comment);
|
||||
Assert.Contains("IMPROVED", comment);
|
||||
Assert.Contains("[-] Became Unreachable", comment);
|
||||
Assert.DoesNotContain("\ud83d\udfe2", comment); // No green circle emoji
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_WithEvidenceAnchors_IncludesEvidenceSection()
|
||||
{
|
||||
// Arrange
|
||||
var summary = CreateSummary(
|
||||
newRiskCount: 0,
|
||||
mitigatedCount: 0,
|
||||
flips: [],
|
||||
attestationDigest: "sha256:abc123def456",
|
||||
policyVerdict: "PASS",
|
||||
policyReasonCode: "NO_BLOCKERS",
|
||||
verifyCommand: "stella scan verify --digest sha256:abc123def456");
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("### Evidence", comment);
|
||||
Assert.Contains("sha256:abc123def456", comment);
|
||||
Assert.Contains("PASS", comment);
|
||||
Assert.Contains("NO_BLOCKERS", comment);
|
||||
Assert.Contains("stella scan verify", comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_DeterministicOrdering_SortsByFlipTypeThenCveId()
|
||||
{
|
||||
// Arrange
|
||||
var flips = new List<StateFlip>
|
||||
{
|
||||
new StateFlip { FlipType = StateFlipType.BecameUnreachable, CveId = "CVE-2026-0001", Purl = "pkg:a", NewTier = "unreachable" },
|
||||
new StateFlip { FlipType = StateFlipType.BecameReachable, CveId = "CVE-2026-0003", Purl = "pkg:b", NewTier = "confirmed" },
|
||||
new StateFlip { FlipType = StateFlipType.BecameReachable, CveId = "CVE-2026-0002", Purl = "pkg:c", NewTier = "likely" },
|
||||
};
|
||||
var summary = CreateSummary(newRiskCount: 2, mitigatedCount: 1, flips: flips);
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert - BecameReachable should come first, then sorted by CVE ID
|
||||
var cve0002Pos = comment.IndexOf("CVE-2026-0002");
|
||||
var cve0003Pos = comment.IndexOf("CVE-2026-0003");
|
||||
var cve0001Pos = comment.IndexOf("CVE-2026-0001");
|
||||
|
||||
// BecameReachable CVEs first (0002, 0003), then BecameUnreachable (0001)
|
||||
Assert.True(cve0002Pos < cve0001Pos, "CVE-2026-0002 (reachable) should appear before CVE-2026-0001 (unreachable)");
|
||||
Assert.True(cve0003Pos < cve0001Pos, "CVE-2026-0003 (reachable) should appear before CVE-2026-0001 (unreachable)");
|
||||
// Within reachable, sorted by CVE ID
|
||||
Assert.True(cve0002Pos < cve0003Pos, "CVE-2026-0002 should appear before CVE-2026-0003 (alphabetical)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_TierChanges_UsesAsciiIndicators()
|
||||
{
|
||||
// Arrange
|
||||
var flips = new List<StateFlip>
|
||||
{
|
||||
new StateFlip { FlipType = StateFlipType.TierIncreased, CveId = "CVE-2026-0001", Purl = "pkg:a", PreviousTier = "present", NewTier = "likely" },
|
||||
new StateFlip { FlipType = StateFlipType.TierDecreased, CveId = "CVE-2026-0002", Purl = "pkg:b", PreviousTier = "likely", NewTier = "present" },
|
||||
};
|
||||
var summary = CreateSummary(newRiskCount: 0, mitigatedCount: 0, flips: flips);
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[^] Tier Increased", comment);
|
||||
Assert.Contains("[v] Tier Decreased", comment);
|
||||
Assert.DoesNotContain("\u2191", comment); // No up arrow
|
||||
Assert.DoesNotContain("\u2193", comment); // No down arrow
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_LimitedTo20Flips_ShowsMoreIndicator()
|
||||
{
|
||||
// Arrange
|
||||
var flips = Enumerable.Range(1, 25)
|
||||
.Select(i => new StateFlip
|
||||
{
|
||||
FlipType = StateFlipType.BecameReachable,
|
||||
CveId = $"CVE-2026-{i:D4}",
|
||||
Purl = $"pkg:test/package-{i}",
|
||||
NewTier = "likely"
|
||||
})
|
||||
.ToList();
|
||||
var summary = CreateSummary(newRiskCount: 25, mitigatedCount: 0, flips: flips);
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("... and 5 more flips", comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_TimestampIsIso8601()
|
||||
{
|
||||
// Arrange
|
||||
var summary = CreateSummary(newRiskCount: 0, mitigatedCount: 0, flips: []);
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("2026-01-15T10:00:00", comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsComment_NoNonAsciiCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var flips = new List<StateFlip>
|
||||
{
|
||||
new StateFlip { FlipType = StateFlipType.BecameReachable, CveId = "CVE-2026-0001", Purl = "pkg:test", NewTier = "confirmed" },
|
||||
new StateFlip { FlipType = StateFlipType.BecameUnreachable, CveId = "CVE-2026-0002", Purl = "pkg:test2", NewTier = "unreachable" },
|
||||
new StateFlip { FlipType = StateFlipType.TierIncreased, CveId = "CVE-2026-0003", Purl = "pkg:test3", NewTier = "likely" },
|
||||
new StateFlip { FlipType = StateFlipType.TierDecreased, CveId = "CVE-2026-0004", Purl = "pkg:test4", NewTier = "present" },
|
||||
};
|
||||
var summary = CreateSummary(
|
||||
newRiskCount: 1,
|
||||
mitigatedCount: 1,
|
||||
flips: flips,
|
||||
shouldBlock: true,
|
||||
attestationDigest: "sha256:test",
|
||||
policyVerdict: "FAIL");
|
||||
|
||||
// Act
|
||||
var comment = _service.FormatAsComment(summary);
|
||||
|
||||
// Assert - Check all characters are ASCII (0-127)
|
||||
foreach (var ch in comment)
|
||||
{
|
||||
Assert.True(ch <= 127, $"Non-ASCII character found: U+{(int)ch:X4} '{ch}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static StateFlipSummary CreateSummary(
|
||||
int newRiskCount,
|
||||
int mitigatedCount,
|
||||
IReadOnlyList<StateFlip> flips,
|
||||
bool shouldBlock = false,
|
||||
string? attestationDigest = null,
|
||||
string? policyVerdict = null,
|
||||
string? policyReasonCode = null,
|
||||
string? verifyCommand = null)
|
||||
{
|
||||
return new StateFlipSummary
|
||||
{
|
||||
BaseScanId = "base-scan-123",
|
||||
HeadScanId = "head-scan-456",
|
||||
HasFlips = flips.Count > 0,
|
||||
NewRiskCount = newRiskCount,
|
||||
MitigatedCount = mitigatedCount,
|
||||
NetChange = newRiskCount - mitigatedCount,
|
||||
ShouldBlockPr = shouldBlock,
|
||||
Summary = $"Test summary: {newRiskCount} new, {mitigatedCount} mitigated",
|
||||
Flips = flips,
|
||||
AttestationDigest = attestationDigest,
|
||||
PolicyVerdict = policyVerdict,
|
||||
PolicyReasonCode = policyReasonCode,
|
||||
VerifyCommand = verifyCommand
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake reachability query service for testing.
|
||||
/// </summary>
|
||||
private sealed class FakeReachabilityQueryService : IReachabilityQueryService
|
||||
{
|
||||
public Task<IReadOnlyDictionary<string, ReachabilityState>> GetReachabilityStatesAsync(
|
||||
string graphId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyDictionary<string, ReachabilityState>>(
|
||||
new Dictionary<string, ReachabilityState>());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user