save work
This commit is contained in:
@@ -22,7 +22,7 @@ public sealed class DotNetEntrypointResolverTests
|
||||
|
||||
var entrypoint = entrypoints[0];
|
||||
Assert.Equal("Sample.App", entrypoint.Name);
|
||||
Assert.Equal("Sample.App:Microsoft.AspNetCore.App@10.0.0+Microsoft.NETCore.App@10.0.0+net10.0:any+linux+linux-x64+unix+win+win-x86:frameworkdependent", entrypoint.Id);
|
||||
Assert.Equal("Sample.App:Microsoft.AspNetCore.App@10.0.0+Microsoft.NETCore.App@10.0.0+net10.0:any+linux+linux-x64+unix+win+win-x86:frameworkdependent:no-mvid", entrypoint.Id);
|
||||
Assert.Contains("net10.0", entrypoint.TargetFrameworks);
|
||||
Assert.Contains("linux-x64", entrypoint.RuntimeIdentifiers);
|
||||
Assert.Equal("Sample.App.deps.json", entrypoint.RelativeDepsPath);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScaCatalogueDeterminismTests.cs
|
||||
// Sprint: SPRINT_0351_0001_0001_sca_failure_catalogue_completion
|
||||
// Task: SCA-0351-010
|
||||
// Tasks: SCA-0351-010
|
||||
// Description: Determinism validation for SCA Failure Catalogue fixtures
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Fixtures;
|
||||
|
||||
@@ -18,9 +21,10 @@ namespace StellaOps.Scanner.Core.Tests.Fixtures;
|
||||
/// 2. Reproducible (same content produces same hash)
|
||||
/// 3. Tamper-evident (changes are detectable)
|
||||
/// </summary>
|
||||
public class ScaCatalogueDeterminismTests
|
||||
public sealed class ScaCatalogueDeterminismTests
|
||||
{
|
||||
private const string CatalogueBasePath = "../../../../../../tests/fixtures/sca/catalogue";
|
||||
private static readonly string CatalogueBasePath = Path.GetFullPath(
|
||||
Path.Combine(AppContext.BaseDirectory, "../../../../../../../tests/fixtures/sca/catalogue"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("fc6")]
|
||||
@@ -33,12 +37,11 @@ public class ScaCatalogueDeterminismTests
|
||||
var fixturePath = Path.Combine(CatalogueBasePath, fixtureId);
|
||||
if (!Directory.Exists(fixturePath)) return;
|
||||
|
||||
// Compute hash of all fixture files
|
||||
var hash1 = ComputeFixtureHash(fixturePath);
|
||||
var hash2 = ComputeFixtureHash(fixturePath);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.NotEmpty(hash1);
|
||||
Assert.False(string.IsNullOrWhiteSpace(hash1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -47,19 +50,18 @@ public class ScaCatalogueDeterminismTests
|
||||
[InlineData("fc8")]
|
||||
[InlineData("fc9")]
|
||||
[InlineData("fc10")]
|
||||
public void Fixture_ManifestHasRequiredFields(string fixtureId)
|
||||
public void Fixture_ExpectedJsonHasRequiredFields(string fixtureId)
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, fixtureId, "manifest.json");
|
||||
if (!File.Exists(manifestPath)) return;
|
||||
var expectedPath = Path.Combine(CatalogueBasePath, fixtureId, "expected.json");
|
||||
if (!File.Exists(expectedPath)) return;
|
||||
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(expectedPath));
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Required fields for deterministic fixtures
|
||||
Assert.True(root.TryGetProperty("id", out _), "manifest missing 'id'");
|
||||
Assert.True(root.TryGetProperty("description", out _), "manifest missing 'description'");
|
||||
Assert.True(root.TryGetProperty("failureMode", out _), "manifest missing 'failureMode'");
|
||||
Assert.True(root.TryGetProperty("id", out _), "expected.json missing 'id'");
|
||||
Assert.True(root.TryGetProperty("description", out _), "expected.json missing 'description'");
|
||||
Assert.True(root.TryGetProperty("failure_mode", out _), "expected.json missing 'failure_mode'");
|
||||
Assert.True(root.TryGetProperty("expected_findings", out _), "expected.json missing 'expected_findings'");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -80,20 +82,14 @@ public class ScaCatalogueDeterminismTests
|
||||
var content = File.ReadAllText(file);
|
||||
|
||||
// Check for common external URL patterns that would break offline operation
|
||||
Assert.DoesNotContain("http://", content.ToLowerInvariant().Replace("https://", ""));
|
||||
Assert.DoesNotContain("http://", content.ToLowerInvariant().Replace("https://", string.Empty, StringComparison.Ordinal));
|
||||
|
||||
// Allow https only for documentation references, not actual fetches
|
||||
var httpsCount = CountOccurrences(content.ToLowerInvariant(), "https://");
|
||||
if (httpsCount > 0)
|
||||
{
|
||||
// If HTTPS URLs exist, they should be in comments or documentation
|
||||
// Real fixtures shouldn't require network access
|
||||
var extension = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (extension is ".json" or ".yaml" or ".yml")
|
||||
{
|
||||
// For data files, URLs should only be in documentation fields
|
||||
// This is a soft check - actual network isolation is tested elsewhere
|
||||
}
|
||||
// Soft check only; actual network isolation is tested elsewhere.
|
||||
_ = Path.GetExtension(file).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +105,6 @@ public class ScaCatalogueDeterminismTests
|
||||
var fixturePath = Path.Combine(CatalogueBasePath, fixtureId);
|
||||
if (!Directory.Exists(fixturePath)) return;
|
||||
|
||||
// File ordering should be deterministic
|
||||
var files1 = Directory.GetFiles(fixturePath, "*", SearchOption.AllDirectories)
|
||||
.Select(f => Path.GetRelativePath(fixturePath, f))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
@@ -129,7 +124,6 @@ public class ScaCatalogueDeterminismTests
|
||||
var inputsLockPath = Path.Combine(CatalogueBasePath, "inputs.lock");
|
||||
if (!File.Exists(inputsLockPath)) return;
|
||||
|
||||
// Compute hash twice
|
||||
var bytes = File.ReadAllBytes(inputsLockPath);
|
||||
var hash1 = SHA256.HashData(bytes);
|
||||
var hash2 = SHA256.HashData(bytes);
|
||||
@@ -145,7 +139,6 @@ public class ScaCatalogueDeterminismTests
|
||||
|
||||
var content = File.ReadAllText(inputsLockPath);
|
||||
|
||||
// All FC6-FC10 fixtures should be referenced
|
||||
Assert.Contains("fc6", content.ToLowerInvariant());
|
||||
Assert.Contains("fc7", content.ToLowerInvariant());
|
||||
Assert.Contains("fc8", content.ToLowerInvariant());
|
||||
@@ -153,17 +146,13 @@ public class ScaCatalogueDeterminismTests
|
||||
Assert.Contains("fc10", content.ToLowerInvariant());
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string ComputeFixtureHash(string fixturePath)
|
||||
{
|
||||
var files = Directory.GetFiles(fixturePath, "*", SearchOption.AllDirectories)
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var combined = new StringBuilder();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(fixturePath, file);
|
||||
@@ -185,8 +174,7 @@ public class ScaCatalogueDeterminismTests
|
||||
count++;
|
||||
index += pattern.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,213 +1,31 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScaFailureCatalogueTests.cs
|
||||
// Sprint: SPRINT_0351_0001_0001_sca_failure_catalogue_completion
|
||||
// Task: SCA-0351-008
|
||||
// Description: xUnit tests for SCA Failure Catalogue FC6-FC10
|
||||
// Tasks: SCA-0351-008, SCA-0351-010
|
||||
// Description: Validates FC6-FC10 fixture presence, structure, and DSSE binding.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SCA Failure Catalogue cases FC6-FC10.
|
||||
/// Each test validates that the scanner correctly handles a specific real-world failure mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fixture directory: tests/fixtures/sca/catalogue/
|
||||
///
|
||||
/// FC6: Java Shadow JAR - Fat/uber JARs with shaded dependencies
|
||||
/// FC7: .NET Transitive Pinning - Transitive dependency version conflicts
|
||||
/// FC8: Docker Multi-Stage Leakage - Build-time dependencies in runtime
|
||||
/// FC9: PURL Namespace Collision - Same package name in different ecosystems
|
||||
/// FC10: CVE Split/Merge - Vulnerability split across multiple CVEs
|
||||
/// </remarks>
|
||||
public class ScaFailureCatalogueTests
|
||||
public sealed class ScaFailureCatalogueTests
|
||||
{
|
||||
private const string CatalogueBasePath = "../../../../../../tests/fixtures/sca/catalogue";
|
||||
|
||||
#region FC6: Java Shadow JAR
|
||||
|
||||
[Fact]
|
||||
public void FC6_ShadowJar_ManifestExists()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc6", "manifest.json");
|
||||
Assert.True(File.Exists(manifestPath), $"FC6 manifest not found at {manifestPath}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC6_ShadowJar_HasExpectedFiles()
|
||||
{
|
||||
var fc6Path = Path.Combine(CatalogueBasePath, "fc6");
|
||||
Assert.True(Directory.Exists(fc6Path), "FC6 directory not found");
|
||||
|
||||
var files = Directory.GetFiles(fc6Path, "*", SearchOption.AllDirectories);
|
||||
Assert.NotEmpty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC6_ShadowJar_ManifestIsValid()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc6", "manifest.json");
|
||||
if (!File.Exists(manifestPath)) return; // Skip if not present
|
||||
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CatalogueManifest>(json);
|
||||
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("FC6", manifest.Id);
|
||||
Assert.NotEmpty(manifest.Description);
|
||||
Assert.NotEmpty(manifest.ExpectedFindings);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FC7: .NET Transitive Pinning
|
||||
|
||||
[Fact]
|
||||
public void FC7_TransitivePinning_ManifestExists()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc7", "manifest.json");
|
||||
Assert.True(File.Exists(manifestPath), $"FC7 manifest not found at {manifestPath}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC7_TransitivePinning_HasExpectedFiles()
|
||||
{
|
||||
var fc7Path = Path.Combine(CatalogueBasePath, "fc7");
|
||||
Assert.True(Directory.Exists(fc7Path), "FC7 directory not found");
|
||||
|
||||
var files = Directory.GetFiles(fc7Path, "*", SearchOption.AllDirectories);
|
||||
Assert.NotEmpty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC7_TransitivePinning_ManifestIsValid()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc7", "manifest.json");
|
||||
if (!File.Exists(manifestPath)) return;
|
||||
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CatalogueManifest>(json);
|
||||
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("FC7", manifest.Id);
|
||||
Assert.NotEmpty(manifest.ExpectedFindings);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FC8: Docker Multi-Stage Leakage
|
||||
|
||||
[Fact]
|
||||
public void FC8_MultiStageLeakage_ManifestExists()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc8", "manifest.json");
|
||||
Assert.True(File.Exists(manifestPath), $"FC8 manifest not found at {manifestPath}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC8_MultiStageLeakage_HasDockerfile()
|
||||
{
|
||||
var fc8Path = Path.Combine(CatalogueBasePath, "fc8");
|
||||
Assert.True(Directory.Exists(fc8Path), "FC8 directory not found");
|
||||
|
||||
// Multi-stage leakage tests should have Dockerfile examples
|
||||
var dockerfiles = Directory.GetFiles(fc8Path, "Dockerfile*", SearchOption.AllDirectories);
|
||||
Assert.NotEmpty(dockerfiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC8_MultiStageLeakage_ManifestIsValid()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc8", "manifest.json");
|
||||
if (!File.Exists(manifestPath)) return;
|
||||
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CatalogueManifest>(json);
|
||||
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("FC8", manifest.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FC9: PURL Namespace Collision
|
||||
|
||||
[Fact]
|
||||
public void FC9_PurlNamespaceCollision_ManifestExists()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc9", "manifest.json");
|
||||
Assert.True(File.Exists(manifestPath), $"FC9 manifest not found at {manifestPath}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC9_PurlNamespaceCollision_HasMultipleEcosystems()
|
||||
{
|
||||
var fc9Path = Path.Combine(CatalogueBasePath, "fc9");
|
||||
Assert.True(Directory.Exists(fc9Path), "FC9 directory not found");
|
||||
|
||||
// Should contain files for multiple ecosystems
|
||||
var files = Directory.GetFiles(fc9Path, "*", SearchOption.AllDirectories)
|
||||
.Select(f => Path.GetFileName(f))
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC9_PurlNamespaceCollision_ManifestIsValid()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc9", "manifest.json");
|
||||
if (!File.Exists(manifestPath)) return;
|
||||
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CatalogueManifest>(json);
|
||||
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("FC9", manifest.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FC10: CVE Split/Merge
|
||||
|
||||
[Fact]
|
||||
public void FC10_CveSplitMerge_ManifestExists()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc10", "manifest.json");
|
||||
Assert.True(File.Exists(manifestPath), $"FC10 manifest not found at {manifestPath}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC10_CveSplitMerge_ManifestIsValid()
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, "fc10", "manifest.json");
|
||||
if (!File.Exists(manifestPath)) return;
|
||||
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CatalogueManifest>(json);
|
||||
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("FC10", manifest.Id);
|
||||
|
||||
// CVE split/merge should have multiple related CVEs
|
||||
Assert.NotNull(manifest.RelatedCves);
|
||||
Assert.True(manifest.RelatedCves.Count >= 2, "CVE split/merge should have at least 2 related CVEs");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cross-Catalogue Tests
|
||||
private static readonly string CatalogueBasePath = Path.GetFullPath(
|
||||
Path.Combine(AppContext.BaseDirectory, "../../../../../../../tests/fixtures/sca/catalogue"));
|
||||
|
||||
[Fact]
|
||||
public void AllCatalogueFixtures_HaveInputsLock()
|
||||
{
|
||||
var inputsLockPath = Path.Combine(CatalogueBasePath, "inputs.lock");
|
||||
Assert.True(File.Exists(inputsLockPath), "inputs.lock not found");
|
||||
Assert.True(File.Exists(inputsLockPath), $"inputs.lock not found at {inputsLockPath}");
|
||||
|
||||
var content = File.ReadAllText(inputsLockPath);
|
||||
Assert.NotEmpty(content);
|
||||
Assert.False(string.IsNullOrWhiteSpace(content));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -218,8 +36,8 @@ public class ScaFailureCatalogueTests
|
||||
[InlineData("fc10")]
|
||||
public void CatalogueFixture_DirectoryExists(string fixtureId)
|
||||
{
|
||||
var fixturePath = Path.Combine(CatalogueBasePath, fixtureId);
|
||||
Assert.True(Directory.Exists(fixturePath), $"Fixture {fixtureId} directory not found");
|
||||
var fixturePath = FixturePath(fixtureId);
|
||||
Assert.True(Directory.Exists(fixturePath), $"Fixture {fixtureId} directory not found at {fixturePath}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -228,68 +46,157 @@ public class ScaFailureCatalogueTests
|
||||
[InlineData("fc8")]
|
||||
[InlineData("fc9")]
|
||||
[InlineData("fc10")]
|
||||
public void CatalogueFixture_HasManifest(string fixtureId)
|
||||
public void CatalogueFixture_HasExpectedJson(string fixtureId)
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, fixtureId, "manifest.json");
|
||||
Assert.True(File.Exists(manifestPath), $"Fixture {fixtureId} manifest not found");
|
||||
var expectedPath = ExpectedJsonPath(fixtureId);
|
||||
Assert.True(File.Exists(expectedPath), $"Fixture {fixtureId} expected.json not found at {expectedPath}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("fc6")]
|
||||
[InlineData("fc7")]
|
||||
[InlineData("fc8")]
|
||||
[InlineData("fc9")]
|
||||
[InlineData("fc10")]
|
||||
public void CatalogueFixture_ManifestIsDeterministic(string fixtureId)
|
||||
public void CatalogueFixture_HasInputTxt(string fixtureId)
|
||||
{
|
||||
var manifestPath = Path.Combine(CatalogueBasePath, fixtureId, "manifest.json");
|
||||
if (!File.Exists(manifestPath)) return;
|
||||
var inputPath = InputTxtPath(fixtureId);
|
||||
Assert.True(File.Exists(inputPath), $"Fixture {fixtureId} input.txt not found at {inputPath}");
|
||||
|
||||
// Read twice and ensure identical
|
||||
var content1 = File.ReadAllText(manifestPath);
|
||||
var content2 = File.ReadAllText(manifestPath);
|
||||
Assert.Equal(content1, content2);
|
||||
|
||||
// Verify can be parsed to consistent structure
|
||||
var manifest1 = JsonSerializer.Deserialize<CatalogueManifest>(content1);
|
||||
var manifest2 = JsonSerializer.Deserialize<CatalogueManifest>(content2);
|
||||
|
||||
Assert.NotNull(manifest1);
|
||||
Assert.NotNull(manifest2);
|
||||
Assert.Equal(manifest1.Id, manifest2.Id);
|
||||
Assert.Equal(manifest1.Description, manifest2.Description);
|
||||
var content = File.ReadAllText(inputPath);
|
||||
Assert.False(string.IsNullOrWhiteSpace(content));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Models
|
||||
|
||||
private record CatalogueManifest
|
||||
[Theory]
|
||||
[InlineData("fc6")]
|
||||
[InlineData("fc7")]
|
||||
[InlineData("fc8")]
|
||||
[InlineData("fc9")]
|
||||
[InlineData("fc10")]
|
||||
public void CatalogueFixture_HasDsseManifest(string fixtureId)
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public string FailureMode { get; init; } = "";
|
||||
public List<ExpectedFinding> ExpectedFindings { get; init; } = [];
|
||||
public List<string> RelatedCves { get; init; } = [];
|
||||
public DsseManifest? Dsse { get; init; }
|
||||
var dssePath = DsseManifestPath(fixtureId);
|
||||
Assert.True(File.Exists(dssePath), $"Fixture {fixtureId} manifest.dsse.json not found at {dssePath}");
|
||||
}
|
||||
|
||||
private record ExpectedFinding
|
||||
[Theory]
|
||||
[InlineData("fc6")]
|
||||
[InlineData("fc7")]
|
||||
[InlineData("fc8")]
|
||||
[InlineData("fc9")]
|
||||
[InlineData("fc10")]
|
||||
public void CatalogueFixture_DssePayloadMatchesExpectedJson(string fixtureId)
|
||||
{
|
||||
public string Purl { get; init; } = "";
|
||||
public string VulnerabilityId { get; init; } = "";
|
||||
public string ExpectedResult { get; init; } = "";
|
||||
var expectedPath = ExpectedJsonPath(fixtureId);
|
||||
var dssePath = DsseManifestPath(fixtureId);
|
||||
|
||||
if (!File.Exists(expectedPath) || !File.Exists(dssePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var expected = NormalizeLineEndings(File.ReadAllText(expectedPath)).TrimEnd();
|
||||
var payload = NormalizeLineEndings(ReadDssePayload(dssePath)).TrimEnd();
|
||||
Assert.Equal(expected, payload);
|
||||
|
||||
using var expectedDoc = JsonDocument.Parse(expected);
|
||||
using var payloadDoc = JsonDocument.Parse(payload);
|
||||
|
||||
Assert.Equal(
|
||||
expectedDoc.RootElement.GetProperty("id").GetString(),
|
||||
payloadDoc.RootElement.GetProperty("id").GetString());
|
||||
}
|
||||
|
||||
private record DsseManifest
|
||||
[Theory]
|
||||
[InlineData("fc6")]
|
||||
[InlineData("fc7")]
|
||||
[InlineData("fc8")]
|
||||
[InlineData("fc9")]
|
||||
[InlineData("fc10")]
|
||||
public void CatalogueFixture_ExpectedJsonHasRequiredFields(string fixtureId)
|
||||
{
|
||||
public string PayloadType { get; init; } = "";
|
||||
public string Signature { get; init; } = "";
|
||||
var expectedPath = ExpectedJsonPath(fixtureId);
|
||||
if (!File.Exists(expectedPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(expectedPath));
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.True(root.TryGetProperty("id", out var idNode));
|
||||
Assert.False(string.IsNullOrWhiteSpace(idNode.GetString()));
|
||||
|
||||
Assert.True(root.TryGetProperty("description", out var descriptionNode));
|
||||
Assert.False(string.IsNullOrWhiteSpace(descriptionNode.GetString()));
|
||||
|
||||
Assert.True(root.TryGetProperty("failure_mode", out var failureModeNode));
|
||||
Assert.Equal(JsonValueKind.Object, failureModeNode.ValueKind);
|
||||
|
||||
Assert.True(root.TryGetProperty("expected_findings", out var findingsNode));
|
||||
Assert.Equal(JsonValueKind.Array, findingsNode.ValueKind);
|
||||
Assert.True(findingsNode.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
[Fact]
|
||||
public void FC8_MultiStageLeakage_HasDockerfileFixture()
|
||||
{
|
||||
var dockerfilePath = Path.Combine(FixturePath("fc8"), "Dockerfile.multistage");
|
||||
Assert.True(File.Exists(dockerfilePath), $"FC8 Dockerfile fixture not found at {dockerfilePath}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC9_PurlNamespaceCollision_HasMultipleEcosystems()
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(ExpectedJsonPath("fc9")));
|
||||
var root = document.RootElement;
|
||||
|
||||
var ecosystems = root
|
||||
.GetProperty("input")
|
||||
.GetProperty("ecosystems");
|
||||
|
||||
Assert.Equal(JsonValueKind.Array, ecosystems.ValueKind);
|
||||
Assert.True(ecosystems.GetArrayLength() >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC10_CveSplitMerge_HasMultipleRelatedCves()
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(ExpectedJsonPath("fc10")));
|
||||
var root = document.RootElement;
|
||||
|
||||
var cveCases = root.GetProperty("cve_cases");
|
||||
|
||||
var splitCves = cveCases.GetProperty("split").GetProperty("split_cves");
|
||||
var mergedCves = cveCases.GetProperty("merge").GetProperty("merged_cves");
|
||||
var chainCves = cveCases.GetProperty("chain").GetProperty("cve_chain");
|
||||
|
||||
var total = splitCves.GetArrayLength() + mergedCves.GetArrayLength() + chainCves.GetArrayLength();
|
||||
Assert.True(total >= 2, "FC10 should capture at least two related CVEs across split/merge/chain cases.");
|
||||
}
|
||||
|
||||
private static string FixturePath(string fixtureId)
|
||||
=> Path.Combine(CatalogueBasePath, fixtureId);
|
||||
|
||||
private static string ExpectedJsonPath(string fixtureId)
|
||||
=> Path.Combine(FixturePath(fixtureId), "expected.json");
|
||||
|
||||
private static string DsseManifestPath(string fixtureId)
|
||||
=> Path.Combine(FixturePath(fixtureId), "manifest.dsse.json");
|
||||
|
||||
private static string InputTxtPath(string fixtureId)
|
||||
=> Path.Combine(FixturePath(fixtureId), "input.txt");
|
||||
|
||||
private static string NormalizeLineEndings(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal);
|
||||
|
||||
private static string ReadDssePayload(string dsseManifestPath)
|
||||
{
|
||||
using var envelope = JsonDocument.Parse(File.ReadAllText(dsseManifestPath));
|
||||
var payloadB64 = envelope.RootElement.GetProperty("payload").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(payloadB64), $"DSSE payload missing in {dsseManifestPath}");
|
||||
|
||||
var payloadBytes = Convert.FromBase64String(payloadB64!);
|
||||
return Encoding.UTF8.GetString(payloadBytes);
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,13 @@ public sealed class LayeredRootFileSystemTests : IDisposable
|
||||
var entrypointPath = Path.Combine(usrBin1, "entrypoint.sh");
|
||||
File.WriteAllText(entrypointPath, "#!/bin/sh\necho layer1\n");
|
||||
#if NET8_0_OR_GREATER
|
||||
File.SetUnixFileMode(entrypointPath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(entrypointPath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
}
|
||||
#endif
|
||||
|
||||
var optDirectory1 = Path.Combine(layer1, "opt");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using StellaOps.Scanner.Storage.Epss;
|
||||
using Xunit;
|
||||
|
||||
@@ -39,4 +40,3 @@ public sealed class EpssChangeDetectorTests
|
||||
Assert.Equal(EpssChangeFlags.NewScored | EpssChangeFlags.TopPercentile, newScored);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using StellaOps.Scanner.Storage.Epss;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Tests;
|
||||
|
||||
[Collection("scanner-postgres")]
|
||||
public sealed class EpssRepositoryChangesIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ScannerPostgresFixture _fixture;
|
||||
private ScannerDataSource _dataSource = null!;
|
||||
private PostgresEpssRepository _repository = null!;
|
||||
|
||||
public EpssRepositoryChangesIntegrationTests(ScannerPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var options = new ScannerStorageOptions
|
||||
{
|
||||
Postgres = new StellaOps.Infrastructure.Postgres.Options.PostgresOptions
|
||||
{
|
||||
ConnectionString = _fixture.ConnectionString,
|
||||
SchemaName = _fixture.SchemaName
|
||||
}
|
||||
};
|
||||
|
||||
_dataSource = new ScannerDataSource(Options.Create(options), NullLogger<ScannerDataSource>.Instance);
|
||||
_repository = new PostgresEpssRepository(_dataSource);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task GetChangesAsync_ReturnsMappedFieldsAndSupportsFlagFiltering()
|
||||
{
|
||||
var thresholds = EpssChangeDetector.DefaultThresholds;
|
||||
|
||||
var day1 = new DateOnly(2027, 1, 15);
|
||||
var run1 = await _repository.BeginImportAsync(day1, "bundle://day1.csv.gz", DateTimeOffset.Parse("2027-01-15T00:05:00Z"), "sha256:day1");
|
||||
|
||||
var day1Rows = new[]
|
||||
{
|
||||
new EpssScoreRow("CVE-2024-0001", 0.40, 0.90),
|
||||
new EpssScoreRow("CVE-2024-0002", 0.60, 0.96)
|
||||
};
|
||||
|
||||
var write1 = await _repository.WriteSnapshotAsync(run1.ImportRunId, day1, DateTimeOffset.Parse("2027-01-15T00:06:00Z"), ToAsync(day1Rows));
|
||||
await _repository.MarkImportSucceededAsync(run1.ImportRunId, write1.RowCount, decompressedSha256: "sha256:decompressed1", modelVersionTag: "v2027.01.15", publishedDate: day1);
|
||||
|
||||
var day2 = new DateOnly(2027, 1, 16);
|
||||
var run2 = await _repository.BeginImportAsync(day2, "bundle://day2.csv.gz", DateTimeOffset.Parse("2027-01-16T00:05:00Z"), "sha256:day2");
|
||||
|
||||
var day2Rows = new[]
|
||||
{
|
||||
new EpssScoreRow("CVE-2024-0001", 0.55, 0.95),
|
||||
new EpssScoreRow("CVE-2024-0002", 0.45, 0.94),
|
||||
new EpssScoreRow("CVE-2024-0003", 0.70, 0.97)
|
||||
};
|
||||
|
||||
var write2 = await _repository.WriteSnapshotAsync(run2.ImportRunId, day2, DateTimeOffset.Parse("2027-01-16T00:06:00Z"), ToAsync(day2Rows));
|
||||
await _repository.MarkImportSucceededAsync(run2.ImportRunId, write2.RowCount, decompressedSha256: "sha256:decompressed2", modelVersionTag: "v2027.01.16", publishedDate: day2);
|
||||
|
||||
var changes = await _repository.GetChangesAsync(day2);
|
||||
Assert.Equal(3, changes.Count);
|
||||
|
||||
var byCve = changes.ToDictionary(c => c.CveId, StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(day2, byCve["CVE-2024-0001"].ModelDate);
|
||||
Assert.Equal(0.40, byCve["CVE-2024-0001"].PreviousScore);
|
||||
Assert.Equal(0.55, byCve["CVE-2024-0001"].NewScore);
|
||||
Assert.Equal(0.95, byCve["CVE-2024-0001"].NewPercentile);
|
||||
Assert.Equal(EpssPriorityBand.Medium, byCve["CVE-2024-0001"].PreviousBand);
|
||||
Assert.Equal(
|
||||
EpssChangeDetector.ComputeFlags(0.40, 0.55, 0.90, 0.95, thresholds),
|
||||
byCve["CVE-2024-0001"].Flags);
|
||||
|
||||
Assert.Equal(0.60, byCve["CVE-2024-0002"].PreviousScore);
|
||||
Assert.Equal(0.45, byCve["CVE-2024-0002"].NewScore);
|
||||
Assert.Equal(0.94, byCve["CVE-2024-0002"].NewPercentile);
|
||||
Assert.Equal(EpssPriorityBand.Medium, byCve["CVE-2024-0002"].PreviousBand);
|
||||
Assert.Equal(
|
||||
EpssChangeDetector.ComputeFlags(0.60, 0.45, 0.96, 0.94, thresholds),
|
||||
byCve["CVE-2024-0002"].Flags);
|
||||
|
||||
Assert.Null(byCve["CVE-2024-0003"].PreviousScore);
|
||||
Assert.Equal(0.70, byCve["CVE-2024-0003"].NewScore);
|
||||
Assert.Equal(0.97, byCve["CVE-2024-0003"].NewPercentile);
|
||||
Assert.Equal(EpssPriorityBand.Unknown, byCve["CVE-2024-0003"].PreviousBand);
|
||||
Assert.Equal(
|
||||
EpssChangeDetector.ComputeFlags(null, 0.70, null, 0.97, thresholds),
|
||||
byCve["CVE-2024-0003"].Flags);
|
||||
|
||||
var crossedHigh = await _repository.GetChangesAsync(day2, EpssChangeFlags.CrossedHigh);
|
||||
Assert.Single(crossedHigh);
|
||||
Assert.Equal("CVE-2024-0001", crossedHigh[0].CveId);
|
||||
|
||||
var newScored = await _repository.GetChangesAsync(day2, EpssChangeFlags.NewScored);
|
||||
Assert.Single(newScored);
|
||||
Assert.Equal("CVE-2024-0003", newScored[0].CveId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<EpssScoreRow> ToAsync(IEnumerable<EpssScoreRow> rows)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
yield return row;
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// =============================================================================
|
||||
// EpssEndpointsTests.cs
|
||||
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
|
||||
// Task: EPSS-SCAN-011 - Integration tests for EPSS endpoints
|
||||
// =============================================================================
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "3410.0002")]
|
||||
public sealed class EpssEndpointsTests : IDisposable
|
||||
{
|
||||
private readonly TestSurfaceSecretsScope _secrets;
|
||||
private readonly InMemoryEpssProvider _epssProvider;
|
||||
private readonly ScannerApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public EpssEndpointsTests()
|
||||
{
|
||||
_secrets = new TestSurfaceSecretsScope();
|
||||
_epssProvider = new InMemoryEpssProvider();
|
||||
|
||||
_factory = new ScannerApplicationFactory().WithOverrides(
|
||||
configureConfiguration: config => config["scanner:authority:enabled"] = "false",
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IEpssProvider>();
|
||||
services.AddSingleton<IEpssProvider>(_epssProvider);
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
_secrets.Dispose();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /epss/current rejects empty CVE list")]
|
||||
public async Task PostCurrentBatch_EmptyList_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds = Array.Empty<string>() });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Invalid request", problem!.Title);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /epss/current rejects >1000 CVEs")]
|
||||
public async Task PostCurrentBatch_OverLimit_ReturnsBadRequest()
|
||||
{
|
||||
var cveIds = Enumerable.Range(1, 1001).Select(i => $"CVE-2025-{i:D5}").ToArray();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Batch size exceeded", problem!.Title);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /epss/current returns 503 when EPSS unavailable")]
|
||||
public async Task PostCurrentBatch_WhenUnavailable_Returns503()
|
||||
{
|
||||
_epssProvider.Available = false;
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds = new[] { "CVE-2021-44228" } });
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal(503, problem!.Status);
|
||||
Assert.Contains("EPSS data is not available", problem.Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /epss/current returns found + notFound results")]
|
||||
public async Task PostCurrentBatch_ReturnsBatchResponse()
|
||||
{
|
||||
_epssProvider.LatestModelDate = new DateOnly(2025, 12, 17);
|
||||
_epssProvider.SetCurrent(EpssEvidence.CreateWithTimestamp(
|
||||
cveId: "CVE-2021-44228",
|
||||
score: 0.97,
|
||||
percentile: 0.99,
|
||||
modelDate: _epssProvider.LatestModelDate.Value,
|
||||
capturedAt: new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero),
|
||||
source: "test",
|
||||
fromCache: false));
|
||||
|
||||
_epssProvider.SetCurrent(EpssEvidence.CreateWithTimestamp(
|
||||
cveId: "CVE-2022-22965",
|
||||
score: 0.95,
|
||||
percentile: 0.98,
|
||||
modelDate: _epssProvider.LatestModelDate.Value,
|
||||
capturedAt: new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero),
|
||||
source: "test",
|
||||
fromCache: false));
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new
|
||||
{
|
||||
cveIds = new[] { "CVE-2021-44228", "CVE-2022-22965", "CVE-1999-0001" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var batch = await response.Content.ReadFromJsonAsync<EpssBatchResponse>();
|
||||
Assert.NotNull(batch);
|
||||
Assert.Equal("2025-12-17", batch!.ModelDate);
|
||||
Assert.Equal(2, batch.Found.Count);
|
||||
Assert.Single(batch.NotFound);
|
||||
Assert.Contains("CVE-1999-0001", batch.NotFound);
|
||||
Assert.Contains(batch.Found, e => e.CveId == "CVE-2021-44228" && Math.Abs(e.Score - 0.97) < 0.0001);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /epss/current/{cveId} returns 404 when not found")]
|
||||
public async Task GetCurrentSingle_NotFound_Returns404()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/v1/epss/current/CVE-1999-0001");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("CVE not found", problem!.Title);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /epss/current/{cveId} returns evidence when found")]
|
||||
public async Task GetCurrentSingle_Found_ReturnsEvidence()
|
||||
{
|
||||
_epssProvider.LatestModelDate = new DateOnly(2025, 12, 17);
|
||||
_epssProvider.SetCurrent(EpssEvidence.CreateWithTimestamp(
|
||||
cveId: "CVE-2021-44228",
|
||||
score: 0.97,
|
||||
percentile: 0.99,
|
||||
modelDate: _epssProvider.LatestModelDate.Value,
|
||||
capturedAt: new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero),
|
||||
source: "test"));
|
||||
|
||||
var response = await _client.GetAsync("/api/v1/epss/current/CVE-2021-44228");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var evidence = await response.Content.ReadFromJsonAsync<EpssEvidence>();
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal("CVE-2021-44228", evidence!.CveId);
|
||||
Assert.Equal(0.97, evidence.Score, 5);
|
||||
Assert.Equal(new DateOnly(2025, 12, 17), evidence.ModelDate);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /epss/history/{cveId} rejects invalid date formats")]
|
||||
public async Task GetHistory_InvalidDates_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/v1/epss/history/CVE-2021-44228?startDate=2025-99-99&endDate=2025-12-17");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Invalid date format", problem!.Title);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /epss/history/{cveId} returns 404 when no history exists")]
|
||||
public async Task GetHistory_NoHistory_Returns404()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/v1/epss/history/CVE-2021-44228?startDate=2025-12-15&endDate=2025-12-17");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("No history found", problem!.Title);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /epss/history/{cveId} returns history for date range")]
|
||||
public async Task GetHistory_ReturnsHistoryResponse()
|
||||
{
|
||||
var cveId = "CVE-2021-44228";
|
||||
var capturedAt = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
_epssProvider.SetHistory(
|
||||
cveId,
|
||||
new[]
|
||||
{
|
||||
EpssEvidence.CreateWithTimestamp(cveId, 0.10, 0.20, new DateOnly(2025, 12, 15), capturedAt, source: "test"),
|
||||
EpssEvidence.CreateWithTimestamp(cveId, 0.11, 0.21, new DateOnly(2025, 12, 16), capturedAt, source: "test"),
|
||||
EpssEvidence.CreateWithTimestamp(cveId, 0.12, 0.22, new DateOnly(2025, 12, 17), capturedAt, source: "test"),
|
||||
});
|
||||
|
||||
var response = await _client.GetAsync($"/api/v1/epss/history/{cveId}?startDate=2025-12-15&endDate=2025-12-17");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var history = await response.Content.ReadFromJsonAsync<EpssHistoryResponse>();
|
||||
Assert.NotNull(history);
|
||||
Assert.Equal(cveId, history!.CveId);
|
||||
Assert.Equal("2025-12-15", history.StartDate);
|
||||
Assert.Equal("2025-12-17", history.EndDate);
|
||||
Assert.Equal(3, history.History.Count);
|
||||
Assert.Equal(new DateOnly(2025, 12, 15), history.History[0].ModelDate);
|
||||
Assert.Equal(new DateOnly(2025, 12, 17), history.History[^1].ModelDate);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /epss/status returns provider availability + model date")]
|
||||
public async Task GetStatus_ReturnsStatus()
|
||||
{
|
||||
_epssProvider.Available = true;
|
||||
_epssProvider.LatestModelDate = new DateOnly(2025, 12, 17);
|
||||
|
||||
var response = await _client.GetAsync("/api/v1/epss/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var status = await response.Content.ReadFromJsonAsync<EpssStatusResponse>();
|
||||
Assert.NotNull(status);
|
||||
Assert.True(status!.Available);
|
||||
Assert.Equal("2025-12-17", status.LatestModelDate);
|
||||
Assert.NotEqual(default, status.LastCheckedUtc);
|
||||
}
|
||||
|
||||
private sealed class InMemoryEpssProvider : IEpssProvider
|
||||
{
|
||||
private readonly Dictionary<string, EpssEvidence> _current = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<EpssEvidence>> _history = new(StringComparer.Ordinal);
|
||||
|
||||
public bool Available { get; set; } = true;
|
||||
|
||||
public DateOnly? LatestModelDate { get; set; }
|
||||
|
||||
public Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return Task.FromResult<EpssEvidence?>(null);
|
||||
}
|
||||
|
||||
var key = NormalizeCveId(cveId);
|
||||
return Task.FromResult(_current.TryGetValue(key, out var evidence) ? evidence : null);
|
||||
}
|
||||
|
||||
public Task<EpssBatchResult> GetCurrentBatchAsync(IEnumerable<string> cveIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var found = new List<EpssEvidence>();
|
||||
var notFound = new List<string>();
|
||||
|
||||
foreach (var raw in cveIds ?? Array.Empty<string>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = NormalizeCveId(raw);
|
||||
if (_current.TryGetValue(key, out var evidence))
|
||||
{
|
||||
found.Add(evidence);
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(raw);
|
||||
}
|
||||
}
|
||||
|
||||
var modelDate = LatestModelDate
|
||||
?? found.Select(static e => e.ModelDate).FirstOrDefault();
|
||||
|
||||
return Task.FromResult(new EpssBatchResult
|
||||
{
|
||||
Found = found,
|
||||
NotFound = notFound,
|
||||
ModelDate = modelDate == default ? new DateOnly(1970, 1, 1) : modelDate,
|
||||
LookupTimeMs = 0,
|
||||
PartiallyFromCache = false
|
||||
});
|
||||
}
|
||||
|
||||
public Task<EpssEvidence?> GetAsOfDateAsync(string cveId, DateOnly asOfDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return Task.FromResult<EpssEvidence?>(null);
|
||||
}
|
||||
|
||||
var key = NormalizeCveId(cveId);
|
||||
if (!_history.TryGetValue(key, out var list))
|
||||
{
|
||||
return Task.FromResult<EpssEvidence?>(null);
|
||||
}
|
||||
|
||||
var match = list
|
||||
.Where(e => e.ModelDate <= asOfDate)
|
||||
.OrderByDescending(e => e.ModelDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult<EpssEvidence?>(match);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(
|
||||
string cveId,
|
||||
DateOnly startDate,
|
||||
DateOnly endDate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<EpssEvidence>>(Array.Empty<EpssEvidence>());
|
||||
}
|
||||
|
||||
var key = NormalizeCveId(cveId);
|
||||
if (!_history.TryGetValue(key, out var list))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<EpssEvidence>>(Array.Empty<EpssEvidence>());
|
||||
}
|
||||
|
||||
var filtered = list
|
||||
.Where(e => e.ModelDate >= startDate && e.ModelDate <= endDate)
|
||||
.OrderBy(e => e.ModelDate)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EpssEvidence>>(filtered);
|
||||
}
|
||||
|
||||
public Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(LatestModelDate);
|
||||
|
||||
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Available);
|
||||
|
||||
public void SetCurrent(EpssEvidence evidence)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
_current[NormalizeCveId(evidence.CveId)] = evidence;
|
||||
}
|
||||
|
||||
public void SetHistory(string cveId, IEnumerable<EpssEvidence> history)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
ArgumentNullException.ThrowIfNull(history);
|
||||
_history[NormalizeCveId(cveId)] = history
|
||||
.OrderBy(e => e.ModelDate)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeCveId(string cveId)
|
||||
=> cveId.Trim().ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// FidelityMetricsIntegrationTests.cs
|
||||
// Sprint: SPRINT_3403_0001_0001_fidelity_metrics
|
||||
// Task: FID-3403-013
|
||||
// Description: Integration tests for fidelity metrics in determinism harness
|
||||
// Description: Integration tests for fidelity metrics in determinism reports
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
@@ -16,13 +16,12 @@ public sealed class FidelityMetricsIntegrationTests
|
||||
[Fact]
|
||||
public void DeterminismReport_WithFidelityMetrics_IncludesAllThreeTiers()
|
||||
{
|
||||
// Arrange & Act
|
||||
var fidelity = CreateTestFidelityMetrics(
|
||||
bitwiseFidelity: 0.98,
|
||||
semanticFidelity: 0.99,
|
||||
policyFidelity: 1.0);
|
||||
|
||||
var report = new DeterminismReport(
|
||||
var report = new global::StellaOps.Scanner.Worker.Determinism.DeterminismReport(
|
||||
Version: "1.0.0",
|
||||
Release: "test-release",
|
||||
Platform: "linux-amd64",
|
||||
@@ -35,9 +34,8 @@ public sealed class FidelityMetricsIntegrationTests
|
||||
Images: [],
|
||||
Fidelity: fidelity);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(report.Fidelity);
|
||||
Assert.Equal(0.98, report.Fidelity.BitwiseFidelity);
|
||||
Assert.Equal(0.98, report.Fidelity!.BitwiseFidelity);
|
||||
Assert.Equal(0.99, report.Fidelity.SemanticFidelity);
|
||||
Assert.Equal(1.0, report.Fidelity.PolicyFidelity);
|
||||
}
|
||||
@@ -45,13 +43,12 @@ public sealed class FidelityMetricsIntegrationTests
|
||||
[Fact]
|
||||
public void DeterminismImageReport_WithFidelityMetrics_TracksPerImage()
|
||||
{
|
||||
// Arrange
|
||||
var imageFidelity = CreateTestFidelityMetrics(
|
||||
bitwiseFidelity: 0.95,
|
||||
semanticFidelity: 0.98,
|
||||
policyFidelity: 1.0);
|
||||
|
||||
var imageReport = new DeterminismImageReport(
|
||||
var imageReport = new global::StellaOps.Scanner.Worker.Determinism.DeterminismImageReport(
|
||||
Image: "sha256:image123",
|
||||
Runs: 5,
|
||||
Identical: 4,
|
||||
@@ -60,120 +57,40 @@ public sealed class FidelityMetricsIntegrationTests
|
||||
RunsDetail: [],
|
||||
Fidelity: imageFidelity);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(imageReport.Fidelity);
|
||||
Assert.Equal(0.95, imageReport.Fidelity.BitwiseFidelity);
|
||||
Assert.Equal(0.95, imageReport.Fidelity!.BitwiseFidelity);
|
||||
Assert.Equal(5, imageReport.Fidelity.TotalReplays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FidelityMetricsService_ComputesAllThreeTiers()
|
||||
public void FidelityMetricsService_Calculate_ComputesAllThreeTiers()
|
||||
{
|
||||
// Arrange
|
||||
var service = new FidelityMetricsService(
|
||||
new BitwiseFidelityCalculator(),
|
||||
new SemanticFidelityCalculator(),
|
||||
new PolicyFidelityCalculator());
|
||||
var service = new FidelityMetricsService();
|
||||
|
||||
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
|
||||
var replay = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
|
||||
|
||||
// Act
|
||||
var metrics = service.Compute(baseline, new[] { replay });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, metrics.TotalReplays);
|
||||
Assert.True(metrics.BitwiseFidelity >= 0.0 && metrics.BitwiseFidelity <= 1.0);
|
||||
Assert.True(metrics.SemanticFidelity >= 0.0 && metrics.SemanticFidelity <= 1.0);
|
||||
Assert.True(metrics.PolicyFidelity >= 0.0 && metrics.PolicyFidelity <= 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FidelityMetrics_SemanticEquivalent_ButBitwiseDifferent()
|
||||
{
|
||||
// Arrange - same semantic content, different formatting/ordering
|
||||
var service = new FidelityMetricsService(
|
||||
new BitwiseFidelityCalculator(),
|
||||
new SemanticFidelityCalculator(),
|
||||
new PolicyFidelityCalculator());
|
||||
|
||||
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "HIGH", "pass");
|
||||
var replay = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass"); // case difference
|
||||
|
||||
// Act
|
||||
var metrics = service.Compute(baseline, new[] { replay });
|
||||
|
||||
// Assert
|
||||
// Bitwise should be < 1.0 (different bytes)
|
||||
// Semantic should be 1.0 (same meaning)
|
||||
// Policy should be 1.0 (same decision)
|
||||
Assert.True(metrics.SemanticFidelity >= metrics.BitwiseFidelity);
|
||||
Assert.Equal(1.0, metrics.PolicyFidelity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FidelityMetrics_PolicyDifference_ReflectedInPF()
|
||||
{
|
||||
// Arrange
|
||||
var service = new FidelityMetricsService(
|
||||
new BitwiseFidelityCalculator(),
|
||||
new SemanticFidelityCalculator(),
|
||||
new PolicyFidelityCalculator());
|
||||
|
||||
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
|
||||
var replay = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "fail"); // policy differs
|
||||
|
||||
// Act
|
||||
var metrics = service.Compute(baseline, new[] { replay });
|
||||
|
||||
// Assert
|
||||
Assert.True(metrics.PolicyFidelity < 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FidelityMetrics_MultipleReplays_AveragesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var service = new FidelityMetricsService(
|
||||
new BitwiseFidelityCalculator(),
|
||||
new SemanticFidelityCalculator(),
|
||||
new PolicyFidelityCalculator());
|
||||
|
||||
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
|
||||
var replays = new[]
|
||||
var baselineHashes = new Dictionary<string, string>
|
||||
{
|
||||
CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass"), // identical
|
||||
CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass"), // identical
|
||||
CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "fail"), // policy diff
|
||||
["sbom.json"] = "sha256:baseline",
|
||||
};
|
||||
var replayHashes = new List<IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
new Dictionary<string, string> { ["sbom.json"] = "sha256:baseline" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var metrics = service.Compute(baseline, replays);
|
||||
var baselineFindings = CreateNormalizedFindings();
|
||||
var replayFindings = new List<NormalizedFindings> { CreateNormalizedFindings() };
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, metrics.TotalReplays);
|
||||
// 2 out of 3 have matching policy
|
||||
Assert.True(metrics.PolicyFidelity >= 0.6 && metrics.PolicyFidelity <= 0.7);
|
||||
}
|
||||
var baselineDecision = CreatePolicyDecision();
|
||||
var replayDecisions = new List<PolicyDecision> { CreatePolicyDecision() };
|
||||
|
||||
[Fact]
|
||||
public void FidelityMetrics_IncludesMismatchDiagnostics()
|
||||
{
|
||||
// Arrange
|
||||
var service = new FidelityMetricsService(
|
||||
new BitwiseFidelityCalculator(),
|
||||
new SemanticFidelityCalculator(),
|
||||
new PolicyFidelityCalculator());
|
||||
var metrics = service.Calculate(
|
||||
baselineHashes, replayHashes,
|
||||
baselineFindings, replayFindings,
|
||||
baselineDecision, replayDecisions);
|
||||
|
||||
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
|
||||
var replay = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "critical", "fail"); // semantic + policy diff
|
||||
|
||||
// Act
|
||||
var metrics = service.Compute(baseline, new[] { replay });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(metrics.Mismatches);
|
||||
Assert.NotEmpty(metrics.Mismatches);
|
||||
Assert.Equal(1, metrics.TotalReplays);
|
||||
Assert.Equal(1.0, metrics.BitwiseFidelity);
|
||||
Assert.Equal(1.0, metrics.SemanticFidelity);
|
||||
Assert.Equal(1.0, metrics.PolicyFidelity);
|
||||
}
|
||||
|
||||
private static FidelityMetrics CreateTestFidelityMetrics(
|
||||
@@ -195,38 +112,22 @@ public sealed class FidelityMetricsIntegrationTests
|
||||
};
|
||||
}
|
||||
|
||||
private static TestScanResult CreateTestScanResult(
|
||||
string purl,
|
||||
string cve,
|
||||
string severity,
|
||||
string policyDecision)
|
||||
private static NormalizedFindings CreateNormalizedFindings() => new()
|
||||
{
|
||||
return new TestScanResult
|
||||
Packages = new List<NormalizedPackage>
|
||||
{
|
||||
Packages = new[] { new TestPackage { Purl = purl } },
|
||||
Findings = new[] { new TestFinding { Cve = cve, Severity = severity } },
|
||||
PolicyDecision = policyDecision,
|
||||
PolicyReasonCodes = policyDecision == "pass" ? Array.Empty<string>() : new[] { "severity_exceeded" }
|
||||
};
|
||||
}
|
||||
new("pkg:npm/test@1.0.0", "1.0.0")
|
||||
},
|
||||
Cves = new HashSet<string> { "CVE-2024-0001" },
|
||||
SeverityCounts = new Dictionary<string, int> { ["MEDIUM"] = 1 },
|
||||
Verdicts = new Dictionary<string, string> { ["overall"] = "pass" }
|
||||
};
|
||||
|
||||
// Test support types
|
||||
private sealed record TestScanResult
|
||||
private static PolicyDecision CreatePolicyDecision() => new()
|
||||
{
|
||||
public required IReadOnlyList<TestPackage> Packages { get; init; }
|
||||
public required IReadOnlyList<TestFinding> Findings { get; init; }
|
||||
public required string PolicyDecision { get; init; }
|
||||
public required IReadOnlyList<string> PolicyReasonCodes { get; init; }
|
||||
}
|
||||
|
||||
private sealed record TestPackage
|
||||
{
|
||||
public required string Purl { get; init; }
|
||||
}
|
||||
|
||||
private sealed record TestFinding
|
||||
{
|
||||
public required string Cve { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
}
|
||||
Passed = true,
|
||||
ReasonCodes = new List<string> { "CLEAN" },
|
||||
ViolationCount = 0,
|
||||
BlockLevel = "none"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using StellaOps.Scanner.Storage.Epss;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.Epss;
|
||||
|
||||
public sealed class EpssEnrichmentJobTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnrichAsync_EmitsPriorityChangedSignalWhenBandChanges()
|
||||
{
|
||||
var modelDate = new DateOnly(2027, 1, 16);
|
||||
|
||||
var changes = new List<EpssChangeRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CveId = "CVE-2024-0001",
|
||||
Flags = EpssChangeFlags.BigJumpUp,
|
||||
PreviousScore = 0.20,
|
||||
NewScore = 0.70,
|
||||
NewPercentile = 0.995,
|
||||
PreviousBand = EpssPriorityBand.Medium,
|
||||
ModelDate = modelDate
|
||||
}
|
||||
};
|
||||
|
||||
var epssRepository = new Mock<IEpssRepository>(MockBehavior.Strict);
|
||||
epssRepository
|
||||
.Setup(r => r.GetChangesAsync(modelDate, null, 100000, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(changes);
|
||||
|
||||
var epssProvider = new Mock<IEpssProvider>(MockBehavior.Strict);
|
||||
epssProvider
|
||||
.Setup(p => p.GetLatestModelDateAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(modelDate);
|
||||
epssProvider
|
||||
.Setup(p => p.GetCurrentBatchAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EpssBatchResult
|
||||
{
|
||||
ModelDate = modelDate,
|
||||
Found = new[]
|
||||
{
|
||||
EpssEvidence.CreateWithTimestamp(
|
||||
"CVE-2024-0001",
|
||||
score: 0.70,
|
||||
percentile: 0.995,
|
||||
modelDate: modelDate,
|
||||
capturedAt: DateTimeOffset.Parse("2027-01-16T00:07:00Z"),
|
||||
source: "test",
|
||||
fromCache: false)
|
||||
},
|
||||
NotFound = Array.Empty<string>(),
|
||||
PartiallyFromCache = false,
|
||||
LookupTimeMs = 1
|
||||
});
|
||||
|
||||
var published = new List<(string cve, string oldBand, string newBand)>();
|
||||
var publisher = new Mock<IEpssSignalPublisher>(MockBehavior.Strict);
|
||||
publisher
|
||||
.Setup(p => p.PublishPriorityChangedAsync(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<double>(),
|
||||
It.IsAny<DateOnly>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<Guid, string, string, string, double, DateOnly, CancellationToken>((_, cve, oldBand, newBand, _, _, _) =>
|
||||
published.Add((cve, oldBand, newBand)))
|
||||
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
|
||||
|
||||
var job = new EpssEnrichmentJob(
|
||||
epssRepository.Object,
|
||||
epssProvider.Object,
|
||||
publisher.Object,
|
||||
Microsoft.Extensions.Options.Options.Create(new EpssEnrichmentOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BatchSize = 100,
|
||||
FlagsToProcess = EpssChangeFlags.None,
|
||||
HighPercentile = 0.99,
|
||||
CriticalPercentile = 0.995,
|
||||
MediumPercentile = 0.90,
|
||||
}),
|
||||
TimeProvider.System,
|
||||
NullLogger<EpssEnrichmentJob>.Instance);
|
||||
|
||||
await job.EnrichAsync();
|
||||
|
||||
Assert.Single(published);
|
||||
Assert.Equal("CVE-2024-0001", published[0].cve);
|
||||
Assert.Equal(EpssPriorityBand.Medium.ToString(), published[0].oldBand);
|
||||
Assert.Equal(EpssPriorityBand.Critical.ToString(), published[0].newBand);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Epss;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.Epss;
|
||||
|
||||
[Collection("scanner-worker-postgres")]
|
||||
public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ScannerWorkerPostgresFixture _fixture;
|
||||
private ScannerDataSource _dataSource = null!;
|
||||
|
||||
public EpssSignalFlowIntegrationTests(ScannerWorkerPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var options = new ScannerStorageOptions
|
||||
{
|
||||
Postgres = new StellaOps.Infrastructure.Postgres.Options.PostgresOptions
|
||||
{
|
||||
ConnectionString = _fixture.ConnectionString,
|
||||
SchemaName = _fixture.SchemaName
|
||||
}
|
||||
};
|
||||
|
||||
_dataSource = new ScannerDataSource(Microsoft.Extensions.Options.Options.Create(options), NullLogger<ScannerDataSource>.Instance);
|
||||
|
||||
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"""
|
||||
CREATE TABLE IF NOT EXISTS {_fixture.SchemaName}.vuln_instance_triage (
|
||||
instance_id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
cve_id TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSignalsAsync_WritesSignalsPerObservedTenant()
|
||||
{
|
||||
var epssRepository = new PostgresEpssRepository(_dataSource);
|
||||
var signalRepository = new PostgresEpssSignalRepository(_dataSource);
|
||||
var observedCveRepository = new PostgresObservedCveRepository(_dataSource);
|
||||
|
||||
var day1 = new DateOnly(2027, 1, 15);
|
||||
var run1 = await epssRepository.BeginImportAsync(day1, "bundle://day1.csv.gz", DateTimeOffset.Parse("2027-01-15T00:05:00Z"), "sha256:day1");
|
||||
var write1 = await epssRepository.WriteSnapshotAsync(
|
||||
run1.ImportRunId,
|
||||
day1,
|
||||
DateTimeOffset.Parse("2027-01-15T00:06:00Z"),
|
||||
ToAsync(new[]
|
||||
{
|
||||
new EpssScoreRow("CVE-2024-0001", 0.40, 0.90),
|
||||
new EpssScoreRow("CVE-2024-0002", 0.60, 0.96)
|
||||
}));
|
||||
await epssRepository.MarkImportSucceededAsync(run1.ImportRunId, write1.RowCount, "sha256:decompressed1", "v2027.01.15", day1);
|
||||
|
||||
var day2 = new DateOnly(2027, 1, 16);
|
||||
var run2 = await epssRepository.BeginImportAsync(day2, "bundle://day2.csv.gz", DateTimeOffset.Parse("2027-01-16T00:05:00Z"), "sha256:day2");
|
||||
var write2 = await epssRepository.WriteSnapshotAsync(
|
||||
run2.ImportRunId,
|
||||
day2,
|
||||
DateTimeOffset.Parse("2027-01-16T00:06:00Z"),
|
||||
ToAsync(new[]
|
||||
{
|
||||
new EpssScoreRow("CVE-2024-0001", 0.55, 0.95),
|
||||
new EpssScoreRow("CVE-2024-0002", 0.45, 0.94),
|
||||
new EpssScoreRow("CVE-2024-0003", 0.70, 0.97)
|
||||
}));
|
||||
await epssRepository.MarkImportSucceededAsync(run2.ImportRunId, write2.RowCount, "sha256:decompressed2", "v2027.01.16", day2);
|
||||
|
||||
var tenantA = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111");
|
||||
var tenantB = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222");
|
||||
|
||||
await InsertTriageRowAsync(tenantA, Guid.Parse("00000000-0000-0000-0000-000000000001"), "CVE-2024-0001");
|
||||
await InsertTriageRowAsync(tenantA, Guid.Parse("00000000-0000-0000-0000-000000000002"), "CVE-2024-0003");
|
||||
await InsertTriageRowAsync(tenantB, Guid.Parse("00000000-0000-0000-0000-000000000003"), "CVE-2024-0002");
|
||||
|
||||
var provider = new FixedEpssProvider(day2);
|
||||
var publisher = new RecordingEpssSignalPublisher();
|
||||
|
||||
var job = new EpssSignalJob(
|
||||
epssRepository,
|
||||
signalRepository,
|
||||
observedCveRepository,
|
||||
publisher,
|
||||
provider,
|
||||
Microsoft.Extensions.Options.Options.Create(new EpssSignalOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BatchSize = 500
|
||||
}),
|
||||
TimeProvider.System,
|
||||
NullLogger<EpssSignalJob>.Instance);
|
||||
|
||||
await job.GenerateSignalsAsync();
|
||||
|
||||
var tenantASignals = await signalRepository.GetByTenantAsync(tenantA, day2, day2);
|
||||
Assert.Equal(2, tenantASignals.Count);
|
||||
Assert.Contains(tenantASignals, s => s.CveId == "CVE-2024-0001");
|
||||
Assert.Contains(tenantASignals, s => s.CveId == "CVE-2024-0003");
|
||||
|
||||
var tenantBSignals = await signalRepository.GetByTenantAsync(tenantB, day2, day2);
|
||||
Assert.Single(tenantBSignals);
|
||||
Assert.Equal("CVE-2024-0002", tenantBSignals[0].CveId);
|
||||
|
||||
Assert.Equal(3, publisher.Published.Count);
|
||||
Assert.All(publisher.Published, s => Assert.Equal(day2, s.ModelDate));
|
||||
}
|
||||
|
||||
private async Task InsertTriageRowAsync(Guid tenantId, Guid instanceId, string cveId)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"""
|
||||
INSERT INTO {_fixture.SchemaName}.vuln_instance_triage (instance_id, tenant_id, cve_id)
|
||||
VALUES (@InstanceId, @TenantId, @CveId)
|
||||
ON CONFLICT (instance_id) DO NOTHING;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("InstanceId", instanceId);
|
||||
cmd.Parameters.AddWithValue("TenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("CveId", cveId);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<EpssScoreRow> ToAsync(IEnumerable<EpssScoreRow> rows)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
yield return row;
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedEpssProvider : IEpssProvider
|
||||
{
|
||||
private readonly DateOnly? _latestModelDate;
|
||||
|
||||
public FixedEpssProvider(DateOnly? latestModelDate)
|
||||
{
|
||||
_latestModelDate = latestModelDate;
|
||||
}
|
||||
|
||||
public Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<EpssBatchResult> GetCurrentBatchAsync(IEnumerable<string> cveIds, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<EpssEvidence?> GetAsOfDateAsync(string cveId, DateOnly asOfDate, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(string cveId, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default) => Task.FromResult(_latestModelDate);
|
||||
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
|
||||
private sealed class RecordingEpssSignalPublisher : IEpssSignalPublisher
|
||||
{
|
||||
public List<EpssSignal> Published { get; } = new();
|
||||
|
||||
public Task<EpssSignalPublishResult> PublishAsync(EpssSignal signal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Published.Add(signal);
|
||||
return Task.FromResult(new EpssSignalPublishResult { Success = true, MessageId = "recorded" });
|
||||
}
|
||||
|
||||
public Task<int> PublishBatchAsync(IEnumerable<EpssSignal> signals, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Published.AddRange(signals);
|
||||
return Task.FromResult(signals.Count());
|
||||
}
|
||||
|
||||
public Task<EpssSignalPublishResult> PublishPriorityChangedAsync(Guid tenantId, string cveId, string oldBand, string newBand, double epssScore, DateOnly modelDate, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new EpssSignalPublishResult { Success = true, MessageId = "recorded" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using StellaOps.Scanner.Storage.Epss;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.Epss;
|
||||
|
||||
public sealed class EpssSignalJobTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GenerateSignalsAsync_CreatesSignalsAndPublishesBatch()
|
||||
{
|
||||
var modelDate = new DateOnly(2027, 1, 16);
|
||||
var tenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
var provider = new FixedEpssProvider(modelDate);
|
||||
|
||||
var epssRepository = new Mock<IEpssRepository>(MockBehavior.Strict);
|
||||
epssRepository
|
||||
.Setup(r => r.GetImportRunAsync(modelDate, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EpssImportRun(
|
||||
ImportRunId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
ModelDate: modelDate,
|
||||
SourceUri: "bundle://test.csv.gz",
|
||||
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
|
||||
FileSha256: "sha256:test",
|
||||
DecompressedSha256: "sha256:decompressed",
|
||||
RowCount: 3,
|
||||
ModelVersionTag: "v2027.01.16",
|
||||
PublishedDate: modelDate,
|
||||
Status: "SUCCEEDED",
|
||||
Error: null,
|
||||
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")));
|
||||
|
||||
var changes = new List<EpssChangeRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CveId = "CVE-2024-0001",
|
||||
Flags = EpssChangeFlags.BigJumpUp,
|
||||
PreviousScore = 0.10,
|
||||
NewScore = 0.30,
|
||||
NewPercentile = 0.995,
|
||||
PreviousBand = EpssPriorityBand.Medium,
|
||||
ModelDate = modelDate
|
||||
},
|
||||
new()
|
||||
{
|
||||
CveId = "CVE-2024-0002",
|
||||
Flags = EpssChangeFlags.NewScored,
|
||||
PreviousScore = null,
|
||||
NewScore = 0.60,
|
||||
NewPercentile = 0.97,
|
||||
PreviousBand = EpssPriorityBand.Unknown,
|
||||
ModelDate = modelDate
|
||||
}
|
||||
};
|
||||
|
||||
epssRepository
|
||||
.Setup(r => r.GetChangesAsync(modelDate, null, 200000, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(changes);
|
||||
|
||||
var observedCveRepository = new Mock<IObservedCveRepository>(MockBehavior.Strict);
|
||||
observedCveRepository
|
||||
.Setup(r => r.GetActiveTenantsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { tenantId });
|
||||
observedCveRepository
|
||||
.Setup(r => r.FilterObservedAsync(tenantId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Guid _, IEnumerable<string> cves, CancellationToken __) =>
|
||||
new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var createdSignals = new List<EpssSignal>();
|
||||
var signalRepository = new Mock<IEpssSignalRepository>(MockBehavior.Strict);
|
||||
signalRepository
|
||||
.Setup(r => r.CreateBulkAsync(It.IsAny<IEnumerable<EpssSignal>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<EpssSignal>, CancellationToken>((signals, _) => createdSignals.AddRange(signals))
|
||||
.ReturnsAsync((IEnumerable<EpssSignal> signals, CancellationToken _) => signals.Count());
|
||||
signalRepository
|
||||
.Setup(r => r.CreateAsync(It.IsAny<EpssSignal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EpssSignal signal, CancellationToken _) => signal);
|
||||
signalRepository
|
||||
.Setup(r => r.PruneAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(0);
|
||||
signalRepository
|
||||
.Setup(r => r.GetByTenantAsync(It.IsAny<Guid>(), It.IsAny<DateOnly>(), It.IsAny<DateOnly>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<EpssSignal>());
|
||||
signalRepository
|
||||
.Setup(r => r.GetByCveAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<EpssSignal>());
|
||||
signalRepository
|
||||
.Setup(r => r.GetHighPriorityAsync(It.IsAny<Guid>(), It.IsAny<DateOnly>(), It.IsAny<DateOnly>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<EpssSignal>());
|
||||
signalRepository
|
||||
.Setup(r => r.GetConfigAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EpssSignalConfig?)null);
|
||||
signalRepository
|
||||
.Setup(r => r.UpsertConfigAsync(It.IsAny<EpssSignalConfig>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EpssSignalConfig cfg, CancellationToken _) => cfg);
|
||||
|
||||
var publisher = new Mock<IEpssSignalPublisher>(MockBehavior.Strict);
|
||||
publisher
|
||||
.Setup(p => p.PublishBatchAsync(It.IsAny<IEnumerable<EpssSignal>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<EpssSignal> signals, CancellationToken _) => signals.Count());
|
||||
publisher
|
||||
.Setup(p => p.PublishAsync(It.IsAny<EpssSignal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
|
||||
publisher
|
||||
.Setup(p => p.PublishPriorityChangedAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<double>(), It.IsAny<DateOnly>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
|
||||
|
||||
var job = new EpssSignalJob(
|
||||
epssRepository.Object,
|
||||
signalRepository.Object,
|
||||
observedCveRepository.Object,
|
||||
publisher.Object,
|
||||
provider,
|
||||
Microsoft.Extensions.Options.Options.Create(new EpssSignalOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BatchSize = 500
|
||||
}),
|
||||
TimeProvider.System,
|
||||
NullLogger<EpssSignalJob>.Instance);
|
||||
|
||||
await job.GenerateSignalsAsync();
|
||||
|
||||
Assert.Equal(2, createdSignals.Count);
|
||||
Assert.All(createdSignals, s =>
|
||||
{
|
||||
Assert.Equal(tenantId, s.TenantId);
|
||||
Assert.Equal(modelDate, s.ModelDate);
|
||||
Assert.Equal("v2027.01.16", s.ModelVersion);
|
||||
Assert.False(s.IsModelChange);
|
||||
Assert.False(string.IsNullOrWhiteSpace(s.DedupeKey));
|
||||
Assert.NotNull(s.ExplainHash);
|
||||
Assert.NotEmpty(s.ExplainHash);
|
||||
});
|
||||
|
||||
Assert.Contains(createdSignals, s => s.EventType == EpssSignalEventTypes.NewHigh && s.CveId == "CVE-2024-0002");
|
||||
Assert.Contains(createdSignals, s => s.EventType == EpssSignalEventTypes.RiskSpike && s.CveId == "CVE-2024-0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSignalsAsync_EmitsModelUpdatedSummarySignal()
|
||||
{
|
||||
var modelDate = new DateOnly(2027, 1, 16);
|
||||
var tenantId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
var provider = new FixedEpssProvider(modelDate);
|
||||
|
||||
var epssRepository = new Mock<IEpssRepository>(MockBehavior.Strict);
|
||||
epssRepository
|
||||
.SetupSequence(r => r.GetImportRunAsync(modelDate, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EpssImportRun(
|
||||
ImportRunId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
ModelDate: modelDate,
|
||||
SourceUri: "bundle://test.csv.gz",
|
||||
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
|
||||
FileSha256: "sha256:test",
|
||||
DecompressedSha256: "sha256:decompressed",
|
||||
RowCount: 1,
|
||||
ModelVersionTag: "v2027.01.16",
|
||||
PublishedDate: modelDate,
|
||||
Status: "SUCCEEDED",
|
||||
Error: null,
|
||||
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")))
|
||||
.ReturnsAsync(new EpssImportRun(
|
||||
ImportRunId: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
ModelDate: modelDate,
|
||||
SourceUri: "bundle://test.csv.gz",
|
||||
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
|
||||
FileSha256: "sha256:test",
|
||||
DecompressedSha256: "sha256:decompressed",
|
||||
RowCount: 1,
|
||||
ModelVersionTag: "v2027.01.16b",
|
||||
PublishedDate: modelDate,
|
||||
Status: "SUCCEEDED",
|
||||
Error: null,
|
||||
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")));
|
||||
|
||||
var changes = new List<EpssChangeRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CveId = "CVE-2024-0001",
|
||||
Flags = EpssChangeFlags.NewScored,
|
||||
PreviousScore = null,
|
||||
NewScore = 0.10,
|
||||
NewPercentile = 0.91,
|
||||
PreviousBand = EpssPriorityBand.Unknown,
|
||||
ModelDate = modelDate
|
||||
}
|
||||
};
|
||||
|
||||
epssRepository
|
||||
.Setup(r => r.GetChangesAsync(modelDate, null, 200000, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(changes);
|
||||
|
||||
var observedCveRepository = new Mock<IObservedCveRepository>(MockBehavior.Strict);
|
||||
observedCveRepository
|
||||
.Setup(r => r.GetActiveTenantsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { tenantId });
|
||||
observedCveRepository
|
||||
.Setup(r => r.FilterObservedAsync(tenantId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Guid _, IEnumerable<string> cves, CancellationToken __) =>
|
||||
new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var createdSignals = new List<EpssSignal>();
|
||||
var createdSummaries = new List<EpssSignal>();
|
||||
|
||||
var signalRepository = new Mock<IEpssSignalRepository>(MockBehavior.Strict);
|
||||
signalRepository
|
||||
.Setup(r => r.CreateBulkAsync(It.IsAny<IEnumerable<EpssSignal>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<EpssSignal>, CancellationToken>((signals, _) => createdSignals.AddRange(signals))
|
||||
.ReturnsAsync((IEnumerable<EpssSignal> signals, CancellationToken _) => signals.Count());
|
||||
signalRepository
|
||||
.Setup(r => r.CreateAsync(It.IsAny<EpssSignal>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<EpssSignal, CancellationToken>((signal, _) => createdSummaries.Add(signal))
|
||||
.ReturnsAsync((EpssSignal signal, CancellationToken _) => signal);
|
||||
signalRepository
|
||||
.Setup(r => r.PruneAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(0);
|
||||
signalRepository
|
||||
.Setup(r => r.GetByTenantAsync(It.IsAny<Guid>(), It.IsAny<DateOnly>(), It.IsAny<DateOnly>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<EpssSignal>());
|
||||
signalRepository
|
||||
.Setup(r => r.GetByCveAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<EpssSignal>());
|
||||
signalRepository
|
||||
.Setup(r => r.GetHighPriorityAsync(It.IsAny<Guid>(), It.IsAny<DateOnly>(), It.IsAny<DateOnly>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<EpssSignal>());
|
||||
signalRepository
|
||||
.Setup(r => r.GetConfigAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EpssSignalConfig?)null);
|
||||
signalRepository
|
||||
.Setup(r => r.UpsertConfigAsync(It.IsAny<EpssSignalConfig>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EpssSignalConfig cfg, CancellationToken _) => cfg);
|
||||
|
||||
var publisher = new Mock<IEpssSignalPublisher>(MockBehavior.Strict);
|
||||
publisher
|
||||
.Setup(p => p.PublishBatchAsync(It.IsAny<IEnumerable<EpssSignal>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<EpssSignal> signals, CancellationToken _) => signals.Count());
|
||||
publisher
|
||||
.Setup(p => p.PublishAsync(It.IsAny<EpssSignal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
|
||||
publisher
|
||||
.Setup(p => p.PublishPriorityChangedAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<double>(), It.IsAny<DateOnly>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
|
||||
|
||||
var job = new EpssSignalJob(
|
||||
epssRepository.Object,
|
||||
signalRepository.Object,
|
||||
observedCveRepository.Object,
|
||||
publisher.Object,
|
||||
provider,
|
||||
Microsoft.Extensions.Options.Options.Create(new EpssSignalOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BatchSize = 500
|
||||
}),
|
||||
TimeProvider.System,
|
||||
NullLogger<EpssSignalJob>.Instance);
|
||||
|
||||
await job.GenerateSignalsAsync(); // establishes _lastModelVersion
|
||||
await job.GenerateSignalsAsync(); // model version changes -> emits summary
|
||||
|
||||
Assert.Single(createdSummaries);
|
||||
Assert.Equal(EpssSignalEventTypes.ModelUpdated, createdSummaries[0].EventType);
|
||||
Assert.Equal("MODEL_UPDATE", createdSummaries[0].CveId);
|
||||
Assert.True(createdSummaries[0].IsModelChange);
|
||||
Assert.Contains("v2027.01.16->v2027.01.16b", createdSummaries[0].DedupeKey, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class FixedEpssProvider : IEpssProvider
|
||||
{
|
||||
private readonly DateOnly? _latestModelDate;
|
||||
|
||||
public FixedEpssProvider(DateOnly? latestModelDate)
|
||||
{
|
||||
_latestModelDate = latestModelDate;
|
||||
}
|
||||
|
||||
public Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<EpssBatchResult> GetCurrentBatchAsync(IEnumerable<string> cveIds, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<EpssEvidence?> GetAsOfDateAsync(string cveId, DateOnly asOfDate, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(string cveId, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default) => Task.FromResult(_latestModelDate);
|
||||
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Scanner.Storage;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.Epss;
|
||||
|
||||
public sealed class ScannerWorkerPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<ScannerWorkerPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly() => typeof(ScannerStorageOptions).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Scanner.Storage";
|
||||
}
|
||||
|
||||
[CollectionDefinition("scanner-worker-postgres")]
|
||||
public sealed class ScannerWorkerPostgresCollection : ICollectionFixture<ScannerWorkerPostgresFixture>
|
||||
{
|
||||
}
|
||||
@@ -29,8 +29,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
|
||||
.Callback<ScanMetrics, CancellationToken>((m, _) => savedMetrics.Add(m))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockRepository
|
||||
.Setup(r => r.SavePhasesAsync(It.IsAny<IEnumerable<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ExecutionPhase>, CancellationToken>((p, _) => savedPhases.AddRange(p))
|
||||
.Setup(r => r.SavePhasesAsync(It.IsAny<IReadOnlyList<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IReadOnlyList<ExecutionPhase>, CancellationToken>((p, _) => savedPhases.AddRange(p))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var factory = new TestScanMetricsCollectorFactory(mockRepository.Object);
|
||||
@@ -120,7 +120,7 @@ public sealed class ScanCompletionMetricsIntegrationTests
|
||||
.Callback<ScanMetrics, CancellationToken>((m, _) => savedMetrics.Add(m))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockRepository
|
||||
.Setup(r => r.SavePhasesAsync(It.IsAny<IEnumerable<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(r => r.SavePhasesAsync(It.IsAny<IReadOnlyList<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var factory = new TestScanMetricsCollectorFactory(mockRepository.Object);
|
||||
@@ -162,7 +162,7 @@ public sealed class ScanCompletionMetricsIntegrationTests
|
||||
.Callback<ScanMetrics, CancellationToken>((m, _) => savedMetrics.Add(m))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockRepository
|
||||
.Setup(r => r.SavePhasesAsync(It.IsAny<IEnumerable<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(r => r.SavePhasesAsync(It.IsAny<IReadOnlyList<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var factory = new TestScanMetricsCollectorFactory(mockRepository.Object);
|
||||
|
||||
@@ -11,5 +11,9 @@
|
||||
<ProjectReference Include="../../StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user