Add call graph fixtures for various languages and scenarios
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET.
- Added `all-visibility-levels.json` to validate method visibility levels in .NET.
- Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application.
- Included `go-gin-api.json` for a Go Gin API application structure.
- Added `java-spring-boot.json` for the Spring PetClinic application in Java.
- Introduced `legacy-no-schema.json` for legacy application structure without schema.
- Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
master
2025-12-16 10:44:24 +02:00
parent 4391f35d8a
commit 5a480a3c2a
223 changed files with 19367 additions and 727 deletions

View File

@@ -0,0 +1,27 @@
[
{
"analyzerId": "node",
"componentKey": "observation::node-phase22",
"name": "Node Observation (Phase 22)",
"type": "node-observation",
"usedByEntrypoint": false,
"capabilities": [],
"threatVectors": [],
"metadata": {
"node.observation.components": "2",
"node.observation.edges": "2",
"node.observation.entrypoints": "0",
"node.observation.native": "1",
"node.observation.wasm": "1"
},
"evidence": [
{
"kind": "derived",
"source": "node.observation",
"locator": "phase22.ndjson",
"value": "{\u0022type\u0022:\u0022component\u0022,\u0022componentType\u0022:\u0022native\u0022,\u0022path\u0022:\u0022/native/addon.node\u0022,\u0022reason\u0022:\u0022native-addon-file\u0022,\u0022confidence\u0022:0.82,\u0022resolverTrace\u0022:[\u0022file:/native/addon.node\u0022],\u0022arch\u0022:\u0022x86_64\u0022,\u0022platform\u0022:\u0022linux\u0022}\r\n{\u0022type\u0022:\u0022component\u0022,\u0022componentType\u0022:\u0022wasm\u0022,\u0022path\u0022:\u0022/pkg/pkg.wasm\u0022,\u0022reason\u0022:\u0022wasm-file\u0022,\u0022confidence\u0022:0.8,\u0022resolverTrace\u0022:[\u0022file:/pkg/pkg.wasm\u0022]}\r\n{\u0022type\u0022:\u0022edge\u0022,\u0022edgeType\u0022:\u0022wasm\u0022,\u0022from\u0022:\u0022/src/app.js\u0022,\u0022to\u0022:\u0022/src/pkg/pkg.wasm\u0022,\u0022reason\u0022:\u0022wasm-import\u0022,\u0022confidence\u0022:0.74,\u0022resolverTrace\u0022:[\u0022source:/src/app.js\u0022,\u0022call:WebAssembly.instantiate(\\u0027./pkg/pkg.wasm\\u0027)\u0022]}\r\n{\u0022type\u0022:\u0022edge\u0022,\u0022edgeType\u0022:\u0022capability\u0022,\u0022from\u0022:\u0022/src/app.js\u0022,\u0022to\u0022:\u0022child_process.execFile\u0022,\u0022reason\u0022:\u0022capability-child-process\u0022,\u0022confidence\u0022:0.7,\u0022resolverTrace\u0022:[\u0022source:/src/app.js\u0022,\u0022call:child_process.execFile\u0022]}",
"sha256": "1329f1c41716d8430b5bdb6d02d1d5f2be1be80877ac15a7e72d3a079fffa4fb"
}
]
}
]

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Configuration;
using Xunit;
namespace StellaOps.Scanner.Core.Tests;
public sealed class OfflineKitOptionsValidatorTests
{
[Fact]
public void Validate_WhenDisabled_SucceedsEvenWithDefaults()
{
var validator = new OfflineKitOptionsValidator();
var result = validator.Validate(null, new OfflineKitOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void Validate_WhenEnabled_RequiresRekorSnapshotDirectory()
{
var validator = new OfflineKitOptionsValidator();
var options = new OfflineKitOptions
{
Enabled = true,
TrustAnchors = new List<TrustAnchorConfig>()
};
var result = validator.Validate(null, options);
Assert.False(result.Succeeded);
Assert.NotNull(result.Failures);
Assert.Contains(result.Failures!, message => message.Contains("RekorSnapshotDirectory", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_WhenEnabled_RequiresTrustRootDirectoryWhenAnchorsPresent()
{
var validator = new OfflineKitOptionsValidator();
var options = new OfflineKitOptions
{
Enabled = true,
RekorOfflineMode = false,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "default",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:abcdef" }
}
}
};
var result = validator.Validate(null, options);
Assert.False(result.Succeeded);
Assert.NotNull(result.Failures);
Assert.Contains(result.Failures!, message => message.Contains("TrustRootDirectory", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_WhenEnabled_WithMinimalValidConfig_Succeeds()
{
var validator = new OfflineKitOptionsValidator();
var trustRootDirectory = CreateTempDirectory("offline-kit-trust-roots");
var rekorSnapshotDirectory = CreateTempDirectory("offline-kit-rekor");
try
{
var options = new OfflineKitOptions
{
Enabled = true,
RequireDsse = true,
RekorOfflineMode = true,
TrustRootDirectory = trustRootDirectory,
RekorSnapshotDirectory = rekorSnapshotDirectory,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "default",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:abcdef" },
MinSignatures = 1
}
}
};
var result = validator.Validate(null, options);
Assert.True(result.Succeeded);
}
finally
{
TryDeleteDirectory(trustRootDirectory);
TryDeleteDirectory(rekorSnapshotDirectory);
}
}
[Fact]
public void Validate_WhenEnabled_DetectsDuplicateAnchorIds()
{
var validator = new OfflineKitOptionsValidator();
var trustRootDirectory = CreateTempDirectory("offline-kit-trust-roots");
var rekorSnapshotDirectory = CreateTempDirectory("offline-kit-rekor");
try
{
var options = new OfflineKitOptions
{
Enabled = true,
RekorOfflineMode = true,
TrustRootDirectory = trustRootDirectory,
RekorSnapshotDirectory = rekorSnapshotDirectory,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "duplicate",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:aaaa" },
},
new()
{
AnchorId = "DUPLICATE",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:bbbb" },
}
}
};
var result = validator.Validate(null, options);
Assert.False(result.Succeeded);
Assert.NotNull(result.Failures);
Assert.Contains(result.Failures!, message => message.Contains("Duplicate", StringComparison.OrdinalIgnoreCase));
}
finally
{
TryDeleteDirectory(trustRootDirectory);
TryDeleteDirectory(rekorSnapshotDirectory);
}
}
private static string CreateTempDirectory(string prefix)
{
var path = Path.Combine(Path.GetTempPath(), $"{prefix}-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
catch
{
}
}
}

View File

@@ -26,7 +26,7 @@ public class ReachabilityUnionPublisherTests
var entry = await cas.TryGetAsync(result.Sha256);
Assert.NotNull(entry);
Assert.True(entry!.Value.SizeBytes > 0);
Assert.True(entry!.SizeBytes > 0);
}
private sealed class TempDir : IDisposable

View File

@@ -53,10 +53,19 @@ public class ReachabilityUnionWriterTests
Assert.Contains("sym:dotnet:B", nodeLines[1]);
// Hashes recorded in meta match content
var meta = await JsonDocument.ParseAsync(File.OpenRead(result.MetaPath));
var files = meta.RootElement.GetProperty("files").EnumerateArray().ToList();
Assert.Contains(files, f => f.GetProperty("path").GetString() == result.Nodes.Path && f.GetProperty("sha256").GetString() == result.Nodes.Sha256);
Assert.Contains(files, f => f.GetProperty("path").GetString() == result.Edges.Path && f.GetProperty("sha256").GetString() == result.Edges.Sha256);
List<(string? Path, string? Sha256)> files;
await using (var metaStream = File.OpenRead(result.MetaPath))
using (var meta = await JsonDocument.ParseAsync(metaStream))
{
files = meta.RootElement
.GetProperty("files")
.EnumerateArray()
.Select(file => (Path: file.GetProperty("path").GetString(), Sha256: file.GetProperty("sha256").GetString()))
.ToList();
}
Assert.Contains(files, file => file.Path == result.Nodes.Path && file.Sha256 == result.Nodes.Sha256);
Assert.Contains(files, file => file.Path == result.Edges.Path && file.Sha256 == result.Edges.Sha256);
// Determinism: re-run with shuffled inputs yields identical hashes
var shuffled = new ReachabilityUnionGraph(

View File

@@ -0,0 +1,32 @@
using StellaOps.Scanner.Core.TrustAnchors;
using Xunit;
namespace StellaOps.Scanner.Core.Tests;
public sealed class PurlPatternMatcherTests
{
[Theory]
[InlineData("*", "pkg:npm/foo@1.0.0", true)]
[InlineData("*", "anything", true)]
[InlineData("*", "", false)]
[InlineData("*", null, false)]
[InlineData("pkg:npm/*", "pkg:npm/foo@1.0.0", true)]
[InlineData("pkg:npm/*", "pkg:maven/org.apache.logging.log4j@2.0.0", false)]
[InlineData("pkg:maven/org.apache.*", "pkg:maven/org.apache.logging.log4j@2.0.0", true)]
[InlineData("pkg:maven/org.apache.*", "pkg:maven/org.eclipse.jetty@11.0.0", false)]
[InlineData("pkg:npm/@scope/pkg@1.0.0", "PKG:NPM/@SCOPE/PKG@1.0.0", true)]
public void IsMatch_HandlesGlobPatterns(string pattern, string? purl, bool expected)
{
var matcher = new PurlPatternMatcher(pattern);
Assert.Equal(expected, matcher.IsMatch(purl));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Constructor_RejectsEmptyPattern(string pattern)
{
Assert.Throws<ArgumentException>(() => new PurlPatternMatcher(pattern));
}
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Configuration;
using StellaOps.Scanner.Core.TrustAnchors;
using Xunit;
namespace StellaOps.Scanner.Core.Tests;
public sealed class TrustAnchorRegistryTests
{
[Fact]
public void ResolveForPurl_ReturnsNullWhenDisabled()
{
var options = new OfflineKitOptions
{
Enabled = false,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "default",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:abcdef" },
}
}
};
var registry = new TrustAnchorRegistry(
new StaticOptionsMonitor<OfflineKitOptions>(options),
new StubKeyLoader(new Dictionary<string, byte[]>()),
NullLogger<TrustAnchorRegistry>.Instance,
TimeProvider.System);
Assert.Null(registry.ResolveForPurl("pkg:npm/foo@1.0.0"));
}
[Fact]
public void ResolveForPurl_FirstMatchWins()
{
var options = new OfflineKitOptions
{
Enabled = true,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "catch-all",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:aaaa" },
},
new()
{
AnchorId = "npm",
PurlPattern = "pkg:npm/*",
AllowedKeyIds = new List<string> { "sha256:bbbb" },
}
}
};
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["aaaa"] = new byte[] { 0x01, 0x02 },
["bbbb"] = new byte[] { 0x03, 0x04 },
};
var registry = new TrustAnchorRegistry(
new StaticOptionsMonitor<OfflineKitOptions>(options),
new StubKeyLoader(keys),
NullLogger<TrustAnchorRegistry>.Instance,
TimeProvider.System);
var resolution = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
Assert.NotNull(resolution);
Assert.Equal("catch-all", resolution!.AnchorId);
}
[Fact]
public void ResolveForPurl_SkipsExpiredAnchors()
{
var options = new OfflineKitOptions
{
Enabled = true,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "expired",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:aaaa" },
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
},
new()
{
AnchorId = "active",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:bbbb" },
}
}
};
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["aaaa"] = new byte[] { 0x01, 0x02 },
["bbbb"] = new byte[] { 0x03, 0x04 },
};
var registry = new TrustAnchorRegistry(
new StaticOptionsMonitor<OfflineKitOptions>(options),
new StubKeyLoader(keys),
NullLogger<TrustAnchorRegistry>.Instance,
TimeProvider.System);
var resolution = registry.ResolveForPurl("pkg:maven/org.example/app@1.0.0");
Assert.NotNull(resolution);
Assert.Equal("active", resolution!.AnchorId);
}
[Fact]
public void ResolveForPurl_NormalizesKeyIdsAndAddsSha256Alias()
{
var options = new OfflineKitOptions
{
Enabled = true,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "npm",
PurlPattern = "pkg:npm/*",
AllowedKeyIds = new List<string> { "sha256:ABCDEF" },
}
}
};
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["abcdef"] = new byte[] { 0x01, 0x02, 0x03 },
};
var registry = new TrustAnchorRegistry(
new StaticOptionsMonitor<OfflineKitOptions>(options),
new StubKeyLoader(keys),
NullLogger<TrustAnchorRegistry>.Instance,
TimeProvider.System);
var resolution = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
Assert.NotNull(resolution);
Assert.Equal(new[] { "abcdef" }, resolution!.AllowedKeyIds);
Assert.True(resolution.PublicKeys.ContainsKey("abcdef"));
Assert.True(resolution.PublicKeys.ContainsKey("sha256:abcdef"));
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
public StaticOptionsMonitor(T currentValue) => CurrentValue = currentValue;
public T CurrentValue { get; }
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
private sealed class StubKeyLoader : IPublicKeyLoader
{
private readonly IReadOnlyDictionary<string, byte[]> _keys;
public StubKeyLoader(IReadOnlyDictionary<string, byte[]> keys) => _keys = keys;
public byte[]? LoadKey(string keyId, string? keyDirectory)
=> _keys.TryGetValue(keyId, out var bytes) ? bytes : null;
}
}

View File

@@ -9,10 +9,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.WebService.Diagnostics;
namespace StellaOps.Scanner.WebService.Tests;
internal sealed class ScannerApplicationFactory : WebApplicationFactory<Program>
internal sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceStatus>
{
private readonly ScannerWebServicePostgresFixture postgresFixture;
private readonly Dictionary<string, string?> configuration = new()

View File

@@ -0,0 +1,163 @@
using StellaOps.Scanner.Worker.Determinism;
using StellaOps.Scanner.Worker.Determinism.Calculators;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests.Determinism;
public sealed class BitwiseFidelityCalculatorTests
{
private readonly BitwiseFidelityCalculator _calculator = new();
[Fact]
public void Calculate_WithEmptyReplays_ReturnsFullScore()
{
var baseline = new Dictionary<string, string>
{
["file1.json"] = "hash1",
["file2.json"] = "hash2"
};
var replays = Array.Empty<IReadOnlyDictionary<string, string>>();
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(1.0, score);
Assert.Equal(0, identicalCount);
Assert.Empty(mismatches);
}
[Fact]
public void Calculate_WithIdenticalReplays_ReturnsFullScore()
{
var baseline = new Dictionary<string, string>
{
["sbom.json"] = "sha256:abc",
["findings.ndjson"] = "sha256:def"
};
var replays = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["sbom.json"] = "sha256:abc",
["findings.ndjson"] = "sha256:def"
},
new Dictionary<string, string>
{
["sbom.json"] = "sha256:abc",
["findings.ndjson"] = "sha256:def"
}
};
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(1.0, score);
Assert.Equal(2, identicalCount);
Assert.Empty(mismatches);
}
[Fact]
public void Calculate_WithPartialMismatch_ReturnsPartialScore()
{
var baseline = new Dictionary<string, string>
{
["sbom.json"] = "sha256:abc",
["findings.ndjson"] = "sha256:def"
};
var replays = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["sbom.json"] = "sha256:abc",
["findings.ndjson"] = "sha256:def"
},
new Dictionary<string, string>
{
["sbom.json"] = "sha256:abc",
["findings.ndjson"] = "sha256:DIFFERENT" // Mismatch
},
new Dictionary<string, string>
{
["sbom.json"] = "sha256:abc",
["findings.ndjson"] = "sha256:def"
}
};
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(2.0 / 3, score, precision: 4);
Assert.Equal(2, identicalCount);
Assert.Single(mismatches);
Assert.Equal(1, mismatches[0].RunIndex);
Assert.Equal(FidelityMismatchType.BitwiseOnly, mismatches[0].Type);
Assert.Contains("findings.ndjson", mismatches[0].AffectedArtifacts!);
}
[Fact]
public void Calculate_WithMissingArtifact_DetectsMismatch()
{
var baseline = new Dictionary<string, string>
{
["file1.json"] = "hash1",
["file2.json"] = "hash2"
};
var replays = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["file1.json"] = "hash1"
// file2.json missing
}
};
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Equal(0, identicalCount);
Assert.Single(mismatches);
Assert.Contains("file2.json", mismatches[0].AffectedArtifacts!);
}
[Fact]
public void Calculate_WithExtraArtifact_DetectsMismatch()
{
var baseline = new Dictionary<string, string>
{
["file1.json"] = "hash1"
};
var replays = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["file1.json"] = "hash1",
["extra.json"] = "extra_hash" // Extra artifact
}
};
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Single(mismatches);
Assert.Contains("extra.json", mismatches[0].AffectedArtifacts!);
}
[Fact]
public void Calculate_IsCaseInsensitiveForHashes()
{
var baseline = new Dictionary<string, string>
{
["file.json"] = "SHA256:ABCDEF"
};
var replays = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["file.json"] = "sha256:abcdef" // Different case
}
};
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(1.0, score);
Assert.Equal(1, identicalCount);
Assert.Empty(mismatches);
}
}

View File

@@ -0,0 +1,174 @@
using StellaOps.Scanner.Worker.Determinism;
using StellaOps.Scanner.Worker.Determinism.Calculators;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests.Determinism;
public sealed class SemanticFidelityCalculatorTests
{
private readonly SemanticFidelityCalculator _calculator = new();
[Fact]
public void Calculate_WithEmptyReplays_ReturnsFullScore()
{
var baseline = CreateBaseline();
var replays = Array.Empty<NormalizedFindings>();
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(1.0, score);
Assert.Equal(0, matchCount);
Assert.Empty(mismatches);
}
[Fact]
public void Calculate_WithIdenticalFindings_ReturnsFullScore()
{
var baseline = CreateBaseline();
var replays = new List<NormalizedFindings>
{
CreateBaseline(),
CreateBaseline()
};
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(1.0, score);
Assert.Equal(2, matchCount);
Assert.Empty(mismatches);
}
[Fact]
public void Calculate_WithDifferentPackages_DetectsMismatch()
{
var baseline = CreateBaseline();
var replays = new List<NormalizedFindings>
{
new NormalizedFindings
{
Packages = new List<NormalizedPackage>
{
new("pkg:npm/lodash@4.17.21", "4.17.21"),
new("pkg:npm/extra@1.0.0", "1.0.0") // Extra package
},
Cves = new HashSet<string> { "CVE-2021-23337" },
SeverityCounts = new Dictionary<string, int> { ["HIGH"] = 1 },
Verdicts = new Dictionary<string, string> { ["overall"] = "fail" }
}
};
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Equal(0, matchCount);
Assert.Single(mismatches);
Assert.Contains("packages", mismatches[0].AffectedArtifacts!);
}
[Fact]
public void Calculate_WithDifferentCves_DetectsMismatch()
{
var baseline = CreateBaseline();
var replays = new List<NormalizedFindings>
{
new NormalizedFindings
{
Packages = new List<NormalizedPackage>
{
new("pkg:npm/lodash@4.17.21", "4.17.21")
},
Cves = new HashSet<string> { "CVE-2021-23337", "CVE-2022-12345" }, // Extra CVE
SeverityCounts = new Dictionary<string, int> { ["HIGH"] = 1 },
Verdicts = new Dictionary<string, string> { ["overall"] = "fail" }
}
};
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Contains("cves", mismatches[0].AffectedArtifacts!);
}
[Fact]
public void Calculate_WithDifferentSeverities_DetectsMismatch()
{
var baseline = CreateBaseline();
var replays = new List<NormalizedFindings>
{
new NormalizedFindings
{
Packages = new List<NormalizedPackage>
{
new("pkg:npm/lodash@4.17.21", "4.17.21")
},
Cves = new HashSet<string> { "CVE-2021-23337" },
SeverityCounts = new Dictionary<string, int> { ["CRITICAL"] = 1 }, // Different severity
Verdicts = new Dictionary<string, string> { ["overall"] = "fail" }
}
};
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Contains("severities", mismatches[0].AffectedArtifacts!);
}
[Fact]
public void Calculate_WithDifferentVerdicts_DetectsMismatch()
{
var baseline = CreateBaseline();
var replays = new List<NormalizedFindings>
{
new NormalizedFindings
{
Packages = new List<NormalizedPackage>
{
new("pkg:npm/lodash@4.17.21", "4.17.21")
},
Cves = new HashSet<string> { "CVE-2021-23337" },
SeverityCounts = new Dictionary<string, int> { ["HIGH"] = 1 },
Verdicts = new Dictionary<string, string> { ["overall"] = "pass" } // Different verdict
}
};
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Contains("verdicts", mismatches[0].AffectedArtifacts!);
}
[Fact]
public void Calculate_WithPartialMatches_ReturnsCorrectScore()
{
var baseline = CreateBaseline();
var replays = new List<NormalizedFindings>
{
CreateBaseline(), // Match
new NormalizedFindings // Mismatch
{
Packages = new List<NormalizedPackage>(),
Cves = new HashSet<string>(),
SeverityCounts = new Dictionary<string, int>(),
Verdicts = new Dictionary<string, string>()
},
CreateBaseline() // Match
};
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(2.0 / 3, score, precision: 4);
Assert.Equal(2, matchCount);
Assert.Single(mismatches);
}
private static NormalizedFindings CreateBaseline() => new()
{
Packages = new List<NormalizedPackage>
{
new("pkg:npm/lodash@4.17.21", "4.17.21")
},
Cves = new HashSet<string> { "CVE-2021-23337" },
SeverityCounts = new Dictionary<string, int> { ["HIGH"] = 1 },
Verdicts = new Dictionary<string, string> { ["overall"] = "fail" }
};
}