sprints and audit work
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CachingVexObservationProviderTests.cs
|
||||
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
|
||||
// Description: Unit tests for CachingVexObservationProvider.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Gate.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CachingVexObservationProvider"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CachingVexObservationProviderTests : IDisposable
|
||||
{
|
||||
private readonly Mock<IVexObservationQuery> _queryMock;
|
||||
private readonly CachingVexObservationProvider _provider;
|
||||
|
||||
public CachingVexObservationProviderTests()
|
||||
{
|
||||
_queryMock = new Mock<IVexObservationQuery>();
|
||||
_provider = new CachingVexObservationProvider(
|
||||
_queryMock.Object,
|
||||
"test-tenant",
|
||||
NullLogger<CachingVexObservationProvider>.Instance,
|
||||
TimeSpan.FromMinutes(5),
|
||||
1000);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVexStatusAsync_CachesMissResult()
|
||||
{
|
||||
_queryMock
|
||||
.Setup(q => q.GetEffectiveStatusAsync(
|
||||
"test-tenant", "CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new VexObservationQueryResult
|
||||
{
|
||||
Status = VexStatus.NotAffected,
|
||||
Confidence = 0.9,
|
||||
LastUpdated = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
// First call - cache miss
|
||||
var result1 = await _provider.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0");
|
||||
Assert.NotNull(result1);
|
||||
Assert.Equal(VexStatus.NotAffected, result1.Status);
|
||||
|
||||
// Second call - should be cache hit
|
||||
var result2 = await _provider.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0");
|
||||
Assert.NotNull(result2);
|
||||
Assert.Equal(VexStatus.NotAffected, result2.Status);
|
||||
|
||||
// Query should only be called once
|
||||
_queryMock.Verify(
|
||||
q => q.GetEffectiveStatusAsync(
|
||||
"test-tenant", "CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVexStatusAsync_ReturnsNull_WhenQueryReturnsNull()
|
||||
{
|
||||
_queryMock
|
||||
.Setup(q => q.GetEffectiveStatusAsync(
|
||||
"test-tenant", "CVE-2025-UNKNOWN", "pkg:npm/unknown@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((VexObservationQueryResult?)null);
|
||||
|
||||
var result = await _provider.GetVexStatusAsync("CVE-2025-UNKNOWN", "pkg:npm/unknown@1.0.0");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatementsAsync_CallsQueryDirectly()
|
||||
{
|
||||
var statements = new List<VexStatementQueryResult>
|
||||
{
|
||||
new()
|
||||
{
|
||||
StatementId = "stmt-1",
|
||||
IssuerId = "vendor",
|
||||
Status = VexStatus.NotAffected,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
},
|
||||
};
|
||||
|
||||
_queryMock
|
||||
.Setup(q => q.GetStatementsAsync(
|
||||
"test-tenant", "CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(statements);
|
||||
|
||||
var result = await _provider.GetStatementsAsync("CVE-2025-1234", "pkg:npm/test@1.0.0");
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("stmt-1", result[0].StatementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrefetchAsync_PopulatesCache()
|
||||
{
|
||||
var batchResults = new Dictionary<VexQueryKey, VexObservationQueryResult>
|
||||
{
|
||||
[new VexQueryKey("CVE-1", "pkg:npm/a@1.0.0")] = new VexObservationQueryResult
|
||||
{
|
||||
Status = VexStatus.NotAffected,
|
||||
Confidence = 0.9,
|
||||
LastUpdated = DateTimeOffset.UtcNow,
|
||||
},
|
||||
[new VexQueryKey("CVE-2", "pkg:npm/b@1.0.0")] = new VexObservationQueryResult
|
||||
{
|
||||
Status = VexStatus.Fixed,
|
||||
Confidence = 0.85,
|
||||
BackportHints = ImmutableArray.Create("backport-1"),
|
||||
LastUpdated = DateTimeOffset.UtcNow,
|
||||
},
|
||||
};
|
||||
|
||||
_queryMock
|
||||
.Setup(q => q.BatchLookupAsync(
|
||||
"test-tenant", It.IsAny<IReadOnlyList<VexQueryKey>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(batchResults);
|
||||
|
||||
var keys = new List<VexLookupKey>
|
||||
{
|
||||
new("CVE-1", "pkg:npm/a@1.0.0"),
|
||||
new("CVE-2", "pkg:npm/b@1.0.0"),
|
||||
};
|
||||
|
||||
await _provider.PrefetchAsync(keys);
|
||||
|
||||
// Now lookups should be cache hits
|
||||
var result1 = await _provider.GetVexStatusAsync("CVE-1", "pkg:npm/a@1.0.0");
|
||||
var result2 = await _provider.GetVexStatusAsync("CVE-2", "pkg:npm/b@1.0.0");
|
||||
|
||||
Assert.NotNull(result1);
|
||||
Assert.Equal(VexStatus.NotAffected, result1.Status);
|
||||
|
||||
Assert.NotNull(result2);
|
||||
Assert.Equal(VexStatus.Fixed, result2.Status);
|
||||
Assert.Single(result2.BackportHints);
|
||||
|
||||
// GetEffectiveStatusAsync should not be called since we prefetched
|
||||
_queryMock.Verify(
|
||||
q => q.GetEffectiveStatusAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrefetchAsync_SkipsAlreadyCachedKeys()
|
||||
{
|
||||
// Pre-populate cache
|
||||
_queryMock
|
||||
.Setup(q => q.GetEffectiveStatusAsync(
|
||||
"test-tenant", "CVE-CACHED", "pkg:npm/cached@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new VexObservationQueryResult
|
||||
{
|
||||
Status = VexStatus.NotAffected,
|
||||
Confidence = 0.9,
|
||||
LastUpdated = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
await _provider.GetVexStatusAsync("CVE-CACHED", "pkg:npm/cached@1.0.0");
|
||||
|
||||
// Now prefetch with the same key
|
||||
var keys = new List<VexLookupKey>
|
||||
{
|
||||
new("CVE-CACHED", "pkg:npm/cached@1.0.0"),
|
||||
};
|
||||
|
||||
await _provider.PrefetchAsync(keys);
|
||||
|
||||
// BatchLookupAsync should not be called since key is already cached
|
||||
_queryMock.Verify(
|
||||
q => q.BatchLookupAsync(
|
||||
It.IsAny<string>(), It.IsAny<IReadOnlyList<VexQueryKey>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrefetchAsync_EmptyList_DoesNothing()
|
||||
{
|
||||
await _provider.PrefetchAsync(new List<VexLookupKey>());
|
||||
|
||||
_queryMock.Verify(
|
||||
q => q.BatchLookupAsync(
|
||||
It.IsAny<string>(), It.IsAny<IReadOnlyList<VexQueryKey>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStatistics_ReturnsCurrentCount()
|
||||
{
|
||||
var stats = _provider.GetStatistics();
|
||||
|
||||
Assert.Equal(0, stats.CurrentEntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cache_IsCaseInsensitive_ForVulnerabilityId()
|
||||
{
|
||||
_queryMock
|
||||
.Setup(q => q.GetEffectiveStatusAsync(
|
||||
"test-tenant", It.IsAny<string>(), "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new VexObservationQueryResult
|
||||
{
|
||||
Status = VexStatus.Fixed,
|
||||
Confidence = 0.8,
|
||||
LastUpdated = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
await _provider.GetVexStatusAsync("cve-2025-1234", "pkg:npm/test@1.0.0");
|
||||
await _provider.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Should be treated as the same key
|
||||
_queryMock.Verify(
|
||||
q => q.GetEffectiveStatusAsync(
|
||||
"test-tenant", It.IsAny<string>(), "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexGatePolicyEvaluatorTests.cs
|
||||
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
|
||||
// Description: Unit tests for VexGatePolicyEvaluator.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Gate.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="VexGatePolicyEvaluator"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VexGatePolicyEvaluatorTests
|
||||
{
|
||||
private readonly VexGatePolicyEvaluator _evaluator;
|
||||
|
||||
public VexGatePolicyEvaluatorTests()
|
||||
{
|
||||
_evaluator = new VexGatePolicyEvaluator(NullLogger<VexGatePolicyEvaluator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExploitableAndReachable_ReturnsBlock()
|
||||
{
|
||||
var evidence = new VexGateEvidence
|
||||
{
|
||||
IsExploitable = true,
|
||||
IsReachable = true,
|
||||
HasCompensatingControl = false,
|
||||
ConfidenceScore = 0.95,
|
||||
SeverityLevel = "critical",
|
||||
};
|
||||
|
||||
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
|
||||
|
||||
Assert.Equal(VexGateDecision.Block, decision);
|
||||
Assert.Equal("block-exploitable-reachable", ruleId);
|
||||
Assert.Contains("Exploitable", rationale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExploitableAndReachableWithControl_ReturnsDefault()
|
||||
{
|
||||
var evidence = new VexGateEvidence
|
||||
{
|
||||
IsExploitable = true,
|
||||
IsReachable = true,
|
||||
HasCompensatingControl = true, // Has control, so block rule doesn't match
|
||||
ConfidenceScore = 0.95,
|
||||
SeverityLevel = "critical",
|
||||
};
|
||||
|
||||
var (decision, ruleId, _) = _evaluator.Evaluate(evidence);
|
||||
|
||||
// With compensating control, the block rule doesn't match
|
||||
// Next matching rule or default applies
|
||||
Assert.NotEqual("block-exploitable-reachable", ruleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_HighSeverityNotReachable_ReturnsWarn()
|
||||
{
|
||||
var evidence = new VexGateEvidence
|
||||
{
|
||||
IsExploitable = true,
|
||||
IsReachable = false,
|
||||
HasCompensatingControl = false,
|
||||
ConfidenceScore = 0.8,
|
||||
SeverityLevel = "high",
|
||||
};
|
||||
|
||||
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
|
||||
|
||||
Assert.Equal(VexGateDecision.Warn, decision);
|
||||
Assert.Equal("warn-high-not-reachable", ruleId);
|
||||
Assert.Contains("not reachable", rationale, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_CriticalSeverityNotReachable_ReturnsWarn()
|
||||
{
|
||||
var evidence = new VexGateEvidence
|
||||
{
|
||||
IsExploitable = true,
|
||||
IsReachable = false,
|
||||
HasCompensatingControl = false,
|
||||
ConfidenceScore = 0.8,
|
||||
SeverityLevel = "critical",
|
||||
};
|
||||
|
||||
var (decision, ruleId, _) = _evaluator.Evaluate(evidence);
|
||||
|
||||
Assert.Equal(VexGateDecision.Warn, decision);
|
||||
Assert.Equal("warn-high-not-reachable", ruleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_VendorNotAffected_ReturnsPass()
|
||||
{
|
||||
var evidence = new VexGateEvidence
|
||||
{
|
||||
VendorStatus = VexStatus.NotAffected,
|
||||
IsExploitable = false,
|
||||
IsReachable = true,
|
||||
HasCompensatingControl = false,
|
||||
ConfidenceScore = 0.9,
|
||||
};
|
||||
|
||||
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
|
||||
|
||||
Assert.Equal(VexGateDecision.Pass, decision);
|
||||
Assert.Equal("pass-vendor-not-affected", ruleId);
|
||||
Assert.Contains("not_affected", rationale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_VendorFixed_ReturnsPass()
|
||||
{
|
||||
var evidence = new VexGateEvidence
|
||||
{
|
||||
VendorStatus = VexStatus.Fixed,
|
||||
IsExploitable = false,
|
||||
IsReachable = true,
|
||||
HasCompensatingControl = false,
|
||||
ConfidenceScore = 0.85,
|
||||
};
|
||||
|
||||
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
|
||||
|
||||
Assert.Equal(VexGateDecision.Pass, decision);
|
||||
Assert.Equal("pass-backport-confirmed", ruleId);
|
||||
Assert.Contains("backport", rationale, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_NoMatchingRules_ReturnsDefaultWarn()
|
||||
{
|
||||
var evidence = new VexGateEvidence
|
||||
{
|
||||
VendorStatus = VexStatus.UnderInvestigation,
|
||||
IsExploitable = false,
|
||||
IsReachable = true,
|
||||
HasCompensatingControl = false,
|
||||
ConfidenceScore = 0.5,
|
||||
SeverityLevel = "low",
|
||||
};
|
||||
|
||||
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
|
||||
|
||||
Assert.Equal(VexGateDecision.Warn, decision);
|
||||
Assert.Equal("default", ruleId);
|
||||
Assert.Contains("default", rationale, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RulesAreEvaluatedInPriorityOrder()
|
||||
{
|
||||
// Evidence matches both block and pass-vendor-not-affected rules
|
||||
// Block has higher priority (100) than pass (80), so block should win
|
||||
var evidence = new VexGateEvidence
|
||||
{
|
||||
VendorStatus = VexStatus.NotAffected, // Would match pass rule
|
||||
IsExploitable = true,
|
||||
IsReachable = true,
|
||||
HasCompensatingControl = false, // Would match block rule
|
||||
ConfidenceScore = 0.9,
|
||||
};
|
||||
|
||||
var (decision, ruleId, _) = _evaluator.Evaluate(evidence);
|
||||
|
||||
// Block rule has higher priority
|
||||
Assert.Equal(VexGateDecision.Block, decision);
|
||||
Assert.Equal("block-exploitable-reachable", ruleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultPolicy_HasExpectedRules()
|
||||
{
|
||||
var policy = VexGatePolicy.Default;
|
||||
|
||||
Assert.Equal(VexGateDecision.Warn, policy.DefaultDecision);
|
||||
Assert.Equal(4, policy.Rules.Length);
|
||||
|
||||
var ruleIds = policy.Rules.Select(r => r.RuleId).ToList();
|
||||
Assert.Contains("block-exploitable-reachable", ruleIds);
|
||||
Assert.Contains("warn-high-not-reachable", ruleIds);
|
||||
Assert.Contains("pass-vendor-not-affected", ruleIds);
|
||||
Assert.Contains("pass-backport-confirmed", ruleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCondition_Matches_AllConditionsMustMatch()
|
||||
{
|
||||
var condition = new VexGatePolicyCondition
|
||||
{
|
||||
IsExploitable = true,
|
||||
IsReachable = true,
|
||||
HasCompensatingControl = false,
|
||||
};
|
||||
|
||||
// All conditions match
|
||||
var matchingEvidence = new VexGateEvidence
|
||||
{
|
||||
IsExploitable = true,
|
||||
IsReachable = true,
|
||||
HasCompensatingControl = false,
|
||||
};
|
||||
Assert.True(condition.Matches(matchingEvidence));
|
||||
|
||||
// One condition doesn't match
|
||||
var nonMatchingEvidence = new VexGateEvidence
|
||||
{
|
||||
IsExploitable = true,
|
||||
IsReachable = false, // Different
|
||||
HasCompensatingControl = false,
|
||||
};
|
||||
Assert.False(condition.Matches(nonMatchingEvidence));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCondition_SeverityLevels_MatchesAny()
|
||||
{
|
||||
var condition = new VexGatePolicyCondition
|
||||
{
|
||||
SeverityLevels = ["critical", "high"],
|
||||
};
|
||||
|
||||
var criticalEvidence = new VexGateEvidence { SeverityLevel = "critical" };
|
||||
var highEvidence = new VexGateEvidence { SeverityLevel = "high" };
|
||||
var mediumEvidence = new VexGateEvidence { SeverityLevel = "medium" };
|
||||
var noSeverityEvidence = new VexGateEvidence();
|
||||
|
||||
Assert.True(condition.Matches(criticalEvidence));
|
||||
Assert.True(condition.Matches(highEvidence));
|
||||
Assert.False(condition.Matches(mediumEvidence));
|
||||
Assert.False(condition.Matches(noSeverityEvidence));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCondition_NullConditionsMatch_AnyEvidence()
|
||||
{
|
||||
var condition = new VexGatePolicyCondition(); // All null
|
||||
|
||||
var anyEvidence = new VexGateEvidence
|
||||
{
|
||||
IsExploitable = true,
|
||||
IsReachable = false,
|
||||
SeverityLevel = "low",
|
||||
};
|
||||
|
||||
Assert.True(condition.Matches(anyEvidence));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user