328 lines
11 KiB
C#
328 lines
11 KiB
C#
// -----------------------------------------------------------------------------
|
|
// VexGateServiceTests.cs
|
|
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
|
|
// Description: Unit tests for VexGateService.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Collections.Immutable;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scanner.Gate.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="VexGateService"/>.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class VexGateServiceTests
|
|
{
|
|
private readonly FakeTimeProvider _timeProvider;
|
|
private readonly VexGatePolicyEvaluator _policyEvaluator;
|
|
private readonly Mock<IVexObservationProvider> _vexProviderMock;
|
|
|
|
public VexGateServiceTests()
|
|
{
|
|
_timeProvider = new FakeTimeProvider(
|
|
new DateTimeOffset(2026, 1, 6, 10, 30, 0, TimeSpan.Zero));
|
|
_policyEvaluator = new VexGatePolicyEvaluator(
|
|
NullLogger<VexGatePolicyEvaluator>.Instance);
|
|
_vexProviderMock = new Mock<IVexObservationProvider>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_WithVexNotAffected_ReturnsPass()
|
|
{
|
|
_vexProviderMock
|
|
.Setup(p => p.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VexObservationResult
|
|
{
|
|
Status = VexStatus.NotAffected,
|
|
Confidence = 0.95,
|
|
});
|
|
|
|
_vexProviderMock
|
|
.Setup(p => p.GetStatementsAsync("CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<VexStatementInfo>
|
|
{
|
|
new()
|
|
{
|
|
StatementId = "stmt-001",
|
|
IssuerId = "vendor-a",
|
|
Status = VexStatus.NotAffected,
|
|
Timestamp = _timeProvider.GetUtcNow().AddDays(-1),
|
|
TrustWeight = 0.9,
|
|
},
|
|
});
|
|
|
|
var service = CreateService();
|
|
|
|
var finding = new VexGateFinding
|
|
{
|
|
FindingId = "finding-001",
|
|
VulnerabilityId = "CVE-2025-1234",
|
|
Purl = "pkg:npm/test@1.0.0",
|
|
ImageDigest = "sha256:abc123",
|
|
IsReachable = true,
|
|
};
|
|
|
|
var result = await service.EvaluateAsync(finding);
|
|
|
|
Assert.Equal(VexGateDecision.Pass, result.Decision);
|
|
Assert.Equal("pass-vendor-not-affected", result.PolicyRuleMatched);
|
|
Assert.Single(result.ContributingStatements);
|
|
Assert.Equal("stmt-001", result.ContributingStatements[0].StatementId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_ExploitableReachable_ReturnsBlock()
|
|
{
|
|
_vexProviderMock
|
|
.Setup(p => p.GetVexStatusAsync("CVE-2025-5678", "pkg:npm/vuln@2.0.0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VexObservationResult
|
|
{
|
|
Status = VexStatus.Affected,
|
|
Confidence = 0.9,
|
|
});
|
|
|
|
_vexProviderMock
|
|
.Setup(p => p.GetStatementsAsync("CVE-2025-5678", "pkg:npm/vuln@2.0.0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<VexStatementInfo>());
|
|
|
|
var service = CreateService();
|
|
|
|
var finding = new VexGateFinding
|
|
{
|
|
FindingId = "finding-002",
|
|
VulnerabilityId = "CVE-2025-5678",
|
|
Purl = "pkg:npm/vuln@2.0.0",
|
|
ImageDigest = "sha256:def456",
|
|
IsReachable = true,
|
|
IsExploitable = true,
|
|
HasCompensatingControl = false,
|
|
SeverityLevel = "critical",
|
|
};
|
|
|
|
var result = await service.EvaluateAsync(finding);
|
|
|
|
Assert.Equal(VexGateDecision.Block, result.Decision);
|
|
Assert.Equal("block-exploitable-reachable", result.PolicyRuleMatched);
|
|
Assert.True(result.Evidence.IsReachable);
|
|
Assert.True(result.Evidence.IsExploitable);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_NoVexProvider_UsesDefaultEvidence()
|
|
{
|
|
var service = new VexGateService(
|
|
_policyEvaluator,
|
|
_timeProvider,
|
|
NullLogger<VexGateService>.Instance,
|
|
vexProvider: null);
|
|
|
|
var finding = new VexGateFinding
|
|
{
|
|
FindingId = "finding-003",
|
|
VulnerabilityId = "CVE-2025-9999",
|
|
Purl = "pkg:npm/unknown@1.0.0",
|
|
ImageDigest = "sha256:xyz789",
|
|
IsReachable = false,
|
|
SeverityLevel = "high",
|
|
};
|
|
|
|
var result = await service.EvaluateAsync(finding);
|
|
|
|
// High severity + not reachable = warn
|
|
Assert.Equal(VexGateDecision.Warn, result.Decision);
|
|
Assert.Null(result.Evidence.VendorStatus);
|
|
Assert.Empty(result.ContributingStatements);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_EvaluatedAtIsSet()
|
|
{
|
|
var service = CreateServiceWithoutVex();
|
|
|
|
var finding = new VexGateFinding
|
|
{
|
|
FindingId = "finding-004",
|
|
VulnerabilityId = "CVE-2025-1111",
|
|
Purl = "pkg:npm/pkg@1.0.0",
|
|
ImageDigest = "sha256:time123",
|
|
};
|
|
|
|
var result = await service.EvaluateAsync(finding);
|
|
|
|
Assert.Equal(_timeProvider.GetUtcNow(), result.EvaluatedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateBatchAsync_ProcessesMultipleFindings()
|
|
{
|
|
var service = CreateServiceWithoutVex();
|
|
|
|
var findings = new List<VexGateFinding>
|
|
{
|
|
new()
|
|
{
|
|
FindingId = "f1",
|
|
VulnerabilityId = "CVE-1",
|
|
Purl = "pkg:npm/a@1.0.0",
|
|
ImageDigest = "sha256:batch",
|
|
IsReachable = true,
|
|
IsExploitable = true,
|
|
HasCompensatingControl = false,
|
|
},
|
|
new()
|
|
{
|
|
FindingId = "f2",
|
|
VulnerabilityId = "CVE-2",
|
|
Purl = "pkg:npm/b@1.0.0",
|
|
ImageDigest = "sha256:batch",
|
|
IsReachable = false,
|
|
SeverityLevel = "high",
|
|
},
|
|
new()
|
|
{
|
|
FindingId = "f3",
|
|
VulnerabilityId = "CVE-3",
|
|
Purl = "pkg:npm/c@1.0.0",
|
|
ImageDigest = "sha256:batch",
|
|
SeverityLevel = "low",
|
|
},
|
|
};
|
|
|
|
var results = await service.EvaluateBatchAsync(findings);
|
|
|
|
Assert.Equal(3, results.Length);
|
|
Assert.Equal(VexGateDecision.Block, results[0].GateResult.Decision);
|
|
Assert.Equal(VexGateDecision.Warn, results[1].GateResult.Decision);
|
|
Assert.Equal(VexGateDecision.Warn, results[2].GateResult.Decision); // Default
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateBatchAsync_EmptyList_ReturnsEmpty()
|
|
{
|
|
var service = CreateServiceWithoutVex();
|
|
|
|
var results = await service.EvaluateBatchAsync(new List<VexGateFinding>());
|
|
|
|
Assert.Empty(results);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateBatchAsync_UsesBatchPrefetch_WhenAvailable()
|
|
{
|
|
var batchProviderMock = new Mock<IVexObservationBatchProvider>();
|
|
var prefetchedKeys = new List<VexLookupKey>();
|
|
|
|
batchProviderMock
|
|
.Setup(p => p.PrefetchAsync(It.IsAny<IReadOnlyList<VexLookupKey>>(), It.IsAny<CancellationToken>()))
|
|
.Callback<IReadOnlyList<VexLookupKey>, CancellationToken>((keys, _) => prefetchedKeys.AddRange(keys))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
batchProviderMock
|
|
.Setup(p => p.GetVexStatusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((VexObservationResult?)null);
|
|
|
|
batchProviderMock
|
|
.Setup(p => p.GetStatementsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<VexStatementInfo>());
|
|
|
|
var service = new VexGateService(
|
|
_policyEvaluator,
|
|
_timeProvider,
|
|
NullLogger<VexGateService>.Instance,
|
|
batchProviderMock.Object);
|
|
|
|
var findings = new List<VexGateFinding>
|
|
{
|
|
new()
|
|
{
|
|
FindingId = "f1",
|
|
VulnerabilityId = "CVE-1",
|
|
Purl = "pkg:npm/a@1.0.0",
|
|
ImageDigest = "sha256:batch",
|
|
},
|
|
new()
|
|
{
|
|
FindingId = "f2",
|
|
VulnerabilityId = "CVE-2",
|
|
Purl = "pkg:npm/b@1.0.0",
|
|
ImageDigest = "sha256:batch",
|
|
},
|
|
};
|
|
|
|
await service.EvaluateBatchAsync(findings);
|
|
|
|
batchProviderMock.Verify(
|
|
p => p.PrefetchAsync(It.IsAny<IReadOnlyList<VexLookupKey>>(), It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
|
|
Assert.Equal(2, prefetchedKeys.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_VexFixed_ReturnsPass()
|
|
{
|
|
_vexProviderMock
|
|
.Setup(p => p.GetVexStatusAsync("CVE-2025-FIXED", "pkg:deb/fixed@1.0.0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VexObservationResult
|
|
{
|
|
Status = VexStatus.Fixed,
|
|
Confidence = 0.85,
|
|
BackportHints = ImmutableArray.Create("deb:1.0.0-2ubuntu1"),
|
|
});
|
|
|
|
_vexProviderMock
|
|
.Setup(p => p.GetStatementsAsync("CVE-2025-FIXED", "pkg:deb/fixed@1.0.0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<VexStatementInfo>
|
|
{
|
|
new()
|
|
{
|
|
StatementId = "stmt-fixed",
|
|
IssuerId = "ubuntu",
|
|
Status = VexStatus.Fixed,
|
|
Timestamp = _timeProvider.GetUtcNow().AddHours(-6),
|
|
TrustWeight = 0.95,
|
|
},
|
|
});
|
|
|
|
var service = CreateService();
|
|
|
|
var finding = new VexGateFinding
|
|
{
|
|
FindingId = "finding-fixed",
|
|
VulnerabilityId = "CVE-2025-FIXED",
|
|
Purl = "pkg:deb/fixed@1.0.0",
|
|
ImageDigest = "sha256:ubuntu",
|
|
IsReachable = true,
|
|
};
|
|
|
|
var result = await service.EvaluateAsync(finding);
|
|
|
|
Assert.Equal(VexGateDecision.Pass, result.Decision);
|
|
Assert.Equal("pass-backport-confirmed", result.PolicyRuleMatched);
|
|
Assert.Single(result.Evidence.BackportHints);
|
|
}
|
|
|
|
private VexGateService CreateService()
|
|
{
|
|
return new VexGateService(
|
|
_policyEvaluator,
|
|
_timeProvider,
|
|
NullLogger<VexGateService>.Instance,
|
|
_vexProviderMock.Object);
|
|
}
|
|
|
|
private VexGateService CreateServiceWithoutVex()
|
|
{
|
|
return new VexGateService(
|
|
_policyEvaluator,
|
|
_timeProvider,
|
|
NullLogger<VexGateService>.Instance,
|
|
vexProvider: null);
|
|
}
|
|
}
|