old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions

This commit is contained in:
master
2026-01-15 18:37:59 +02:00
parent c631bacee2
commit 88a85cdd92
208 changed files with 32271 additions and 2287 deletions

View File

@@ -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
};
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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>());
}
}
}