Add unit and integration tests for VexCandidateEmitter and SmartDiff repositories
- Implemented comprehensive unit tests for VexCandidateEmitter to validate candidate emission logic based on various scenarios including absent and present APIs, confidence thresholds, and rate limiting. - Added integration tests for SmartDiff PostgreSQL repositories, covering snapshot storage and retrieval, candidate storage, and material risk change handling. - Ensured tests validate correct behavior for storing, retrieving, and querying snapshots and candidates, including edge cases and expected outcomes.
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
{
|
||||
"$schema": "https://stellaops.io/schemas/smart-diff/v1/state-comparison.json",
|
||||
"version": "1.0.0",
|
||||
"description": "Golden fixtures for Smart-Diff state comparison determinism testing",
|
||||
"testCases": [
|
||||
{
|
||||
"id": "R1-001",
|
||||
"name": "Reachability flip: unreachable to reachable",
|
||||
"rule": "R1_ReachabilityFlip",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-1234",
|
||||
"purl": "pkg:npm/lodash@4.17.20"
|
||||
},
|
||||
"scanId": "scan-prev-001",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": false,
|
||||
"latticeState": "SU",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.05,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-1234",
|
||||
"purl": "pkg:npm/lodash@4.17.20"
|
||||
},
|
||||
"scanId": "scan-curr-001",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "CR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.05,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "increased",
|
||||
"changeType": "reachability_flip",
|
||||
"priorityScoreContribution": 500
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "R1-002",
|
||||
"name": "Reachability flip: reachable to unreachable",
|
||||
"rule": "R1_ReachabilityFlip",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-5678",
|
||||
"purl": "pkg:pypi/requests@2.28.0"
|
||||
},
|
||||
"scanId": "scan-prev-002",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "CR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.10,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-5678",
|
||||
"purl": "pkg:pypi/requests@2.28.0"
|
||||
},
|
||||
"scanId": "scan-curr-002",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": false,
|
||||
"latticeState": "CU",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.10,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "decreased",
|
||||
"changeType": "reachability_flip",
|
||||
"priorityScoreContribution": 500
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "R2-001",
|
||||
"name": "VEX flip: affected to not_affected",
|
||||
"rule": "R2_VexFlip",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-9999",
|
||||
"purl": "pkg:maven/org.example/core@1.0.0"
|
||||
},
|
||||
"scanId": "scan-prev-003",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.02,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-9999",
|
||||
"purl": "pkg:maven/org.example/core@1.0.0"
|
||||
},
|
||||
"scanId": "scan-curr-003",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "not_affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.02,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "decreased",
|
||||
"changeType": "vex_flip",
|
||||
"priorityScoreContribution": 150
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "R2-002",
|
||||
"name": "VEX flip: not_affected to affected",
|
||||
"rule": "R2_VexFlip",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-8888",
|
||||
"purl": "pkg:golang/github.com/example/pkg@v1.2.3"
|
||||
},
|
||||
"scanId": "scan-prev-004",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "not_affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.03,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-8888",
|
||||
"purl": "pkg:golang/github.com/example/pkg@v1.2.3"
|
||||
},
|
||||
"scanId": "scan-curr-004",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.03,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "increased",
|
||||
"changeType": "vex_flip",
|
||||
"priorityScoreContribution": 150
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "R3-001",
|
||||
"name": "Range boundary: exits affected range",
|
||||
"rule": "R3_RangeBoundary",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-7777",
|
||||
"purl": "pkg:npm/express@4.17.0"
|
||||
},
|
||||
"scanId": "scan-prev-005",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.04,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-7777",
|
||||
"purl": "pkg:npm/express@4.18.0"
|
||||
},
|
||||
"scanId": "scan-curr-005",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": false,
|
||||
"kev": false,
|
||||
"epssScore": 0.04,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "decreased",
|
||||
"changeType": "range_boundary",
|
||||
"priorityScoreContribution": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "R4-001",
|
||||
"name": "KEV added",
|
||||
"rule": "R4_IntelligenceFlip",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-6666",
|
||||
"purl": "pkg:npm/axios@0.21.0"
|
||||
},
|
||||
"scanId": "scan-prev-006",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.08,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-6666",
|
||||
"purl": "pkg:npm/axios@0.21.0"
|
||||
},
|
||||
"scanId": "scan-curr-006",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": true,
|
||||
"epssScore": 0.45,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "increased",
|
||||
"changeType": "kev_added",
|
||||
"priorityScoreContribution": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "R4-002",
|
||||
"name": "EPSS crosses threshold (0.1)",
|
||||
"rule": "R4_IntelligenceFlip",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-5555",
|
||||
"purl": "pkg:pypi/django@3.2.0"
|
||||
},
|
||||
"scanId": "scan-prev-007",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.05,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-5555",
|
||||
"purl": "pkg:pypi/django@3.2.0"
|
||||
},
|
||||
"scanId": "scan-curr-007",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.15,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "increased",
|
||||
"changeType": "epss_threshold",
|
||||
"priorityScoreContribution": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "R4-003",
|
||||
"name": "Policy flip: allow to block",
|
||||
"rule": "R4_IntelligenceFlip",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-4444",
|
||||
"purl": "pkg:npm/moment@2.29.0"
|
||||
},
|
||||
"scanId": "scan-prev-008",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.06,
|
||||
"policyFlags": [],
|
||||
"policyDecision": "allow"
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-4444",
|
||||
"purl": "pkg:npm/moment@2.29.0"
|
||||
},
|
||||
"scanId": "scan-curr-008",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.06,
|
||||
"policyFlags": ["HIGH_SEVERITY"],
|
||||
"policyDecision": "block"
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "increased",
|
||||
"changeType": "policy_flip",
|
||||
"priorityScoreContribution": 300
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "MULTI-001",
|
||||
"name": "Multiple changes: KEV + reachability flip",
|
||||
"rule": "Multiple",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-3333",
|
||||
"purl": "pkg:npm/jquery@3.5.0"
|
||||
},
|
||||
"scanId": "scan-prev-009",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": false,
|
||||
"latticeState": "SU",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.07,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-3333",
|
||||
"purl": "pkg:npm/jquery@3.5.0"
|
||||
},
|
||||
"scanId": "scan-curr-009",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "CR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": true,
|
||||
"epssScore": 0.35,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": true,
|
||||
"direction": "increased",
|
||||
"changeCount": 2,
|
||||
"totalPriorityScore": 1500
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "NO-CHANGE-001",
|
||||
"name": "No material change - identical states",
|
||||
"rule": "None",
|
||||
"previous": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-2222",
|
||||
"purl": "pkg:npm/underscore@1.13.0"
|
||||
},
|
||||
"scanId": "scan-prev-010",
|
||||
"capturedAt": "2024-12-01T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.02,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"current": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-2222",
|
||||
"purl": "pkg:npm/underscore@1.13.0"
|
||||
},
|
||||
"scanId": "scan-curr-010",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "SR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.02,
|
||||
"policyFlags": [],
|
||||
"policyDecision": null
|
||||
},
|
||||
"expected": {
|
||||
"hasMaterialChange": false,
|
||||
"changeCount": 0,
|
||||
"totalPriorityScore": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"stateHashTestCases": [
|
||||
{
|
||||
"id": "HASH-001",
|
||||
"name": "State hash determinism - same input produces same hash",
|
||||
"state": {
|
||||
"findingKey": {
|
||||
"vulnId": "CVE-2024-1111",
|
||||
"purl": "pkg:npm/test@1.0.0"
|
||||
},
|
||||
"scanId": "scan-hash-001",
|
||||
"capturedAt": "2024-12-15T10:00:00Z",
|
||||
"reachable": true,
|
||||
"latticeState": "CR",
|
||||
"vexStatus": "affected",
|
||||
"inAffectedRange": true,
|
||||
"kev": false,
|
||||
"epssScore": 0.05,
|
||||
"policyFlags": ["FLAG_A", "FLAG_B"],
|
||||
"policyDecision": "warn"
|
||||
},
|
||||
"expectedHashPrefix": "sha256:"
|
||||
},
|
||||
{
|
||||
"id": "HASH-002",
|
||||
"name": "State hash differs with reachability change",
|
||||
"state1": {
|
||||
"reachable": true,
|
||||
"vexStatus": "affected"
|
||||
},
|
||||
"state2": {
|
||||
"reachable": false,
|
||||
"vexStatus": "affected"
|
||||
},
|
||||
"expectDifferentHash": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiff.Tests;
|
||||
|
||||
public class MaterialRiskChangeDetectorTests
|
||||
{
|
||||
private readonly MaterialRiskChangeDetector _detector = new();
|
||||
|
||||
private static RiskStateSnapshot CreateSnapshot(
|
||||
string vulnId = "CVE-2024-1234",
|
||||
string purl = "pkg:npm/example@1.0.0",
|
||||
string scanId = "scan-1",
|
||||
bool? reachable = null,
|
||||
VexStatusType vexStatus = VexStatusType.Unknown,
|
||||
bool? inAffectedRange = null,
|
||||
bool kev = false,
|
||||
double? epssScore = null,
|
||||
PolicyDecisionType? policyDecision = null)
|
||||
{
|
||||
return new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey(vulnId, purl),
|
||||
ScanId: scanId,
|
||||
CapturedAt: DateTimeOffset.UtcNow,
|
||||
Reachable: reachable,
|
||||
LatticeState: null,
|
||||
VexStatus: vexStatus,
|
||||
InAffectedRange: inAffectedRange,
|
||||
Kev: kev,
|
||||
EpssScore: epssScore,
|
||||
PolicyFlags: [],
|
||||
PolicyDecision: policyDecision);
|
||||
}
|
||||
|
||||
#region R1: Reachability Flip Tests
|
||||
|
||||
[Fact]
|
||||
public void R1_Detects_ReachabilityFlip_FalseToTrue()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(reachable: false);
|
||||
var curr = CreateSnapshot(reachable: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Single(result.Changes);
|
||||
Assert.Equal(DetectionRule.R1_ReachabilityFlip, result.Changes[0].Rule);
|
||||
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R1_Detects_ReachabilityFlip_TrueToFalse()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(reachable: true);
|
||||
var curr = CreateSnapshot(reachable: false);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Single(result.Changes);
|
||||
Assert.Equal(DetectionRule.R1_ReachabilityFlip, result.Changes[0].Rule);
|
||||
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R1_Ignores_NullToValue()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(reachable: null);
|
||||
var curr = CreateSnapshot(reachable: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasMaterialChange);
|
||||
Assert.Empty(result.Changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R1_Ignores_NoChange()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(reachable: true);
|
||||
var curr = CreateSnapshot(reachable: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasMaterialChange);
|
||||
Assert.Empty(result.Changes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region R2: VEX Status Flip Tests
|
||||
|
||||
[Fact]
|
||||
public void R2_Detects_VexFlip_NotAffectedToAffected()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(vexStatus: VexStatusType.NotAffected);
|
||||
var curr = CreateSnapshot(vexStatus: VexStatusType.Affected);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Single(result.Changes);
|
||||
Assert.Equal(DetectionRule.R2_VexFlip, result.Changes[0].Rule);
|
||||
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R2_Detects_VexFlip_AffectedToFixed()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(vexStatus: VexStatusType.Affected);
|
||||
var curr = CreateSnapshot(vexStatus: VexStatusType.Fixed);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Single(result.Changes);
|
||||
Assert.Equal(DetectionRule.R2_VexFlip, result.Changes[0].Rule);
|
||||
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R2_Detects_VexFlip_UnknownToAffected()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(vexStatus: VexStatusType.Unknown);
|
||||
var curr = CreateSnapshot(vexStatus: VexStatusType.Affected);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R2_Ignores_NonMeaningfulTransition()
|
||||
{
|
||||
// Arrange - Fixed to NotAffected isn't meaningful (both safe states)
|
||||
var prev = CreateSnapshot(vexStatus: VexStatusType.Fixed);
|
||||
var curr = CreateSnapshot(vexStatus: VexStatusType.NotAffected);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasMaterialChange);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region R3: Affected Range Boundary Tests
|
||||
|
||||
[Fact]
|
||||
public void R3_Detects_RangeEntry()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(inAffectedRange: false);
|
||||
var curr = CreateSnapshot(inAffectedRange: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Single(result.Changes);
|
||||
Assert.Equal(DetectionRule.R3_RangeBoundary, result.Changes[0].Rule);
|
||||
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R3_Detects_RangeExit()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(inAffectedRange: true);
|
||||
var curr = CreateSnapshot(inAffectedRange: false);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Single(result.Changes);
|
||||
Assert.Equal(DetectionRule.R3_RangeBoundary, result.Changes[0].Rule);
|
||||
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R3_Ignores_NullTransition()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(inAffectedRange: null);
|
||||
var curr = CreateSnapshot(inAffectedRange: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasMaterialChange);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region R4: Intelligence/Policy Flip Tests
|
||||
|
||||
[Fact]
|
||||
public void R4_Detects_KevAdded()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(kev: false);
|
||||
var curr = CreateSnapshot(kev: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Single(result.Changes);
|
||||
Assert.Equal(DetectionRule.R4_IntelligenceFlip, result.Changes[0].Rule);
|
||||
Assert.Equal(MaterialChangeType.KevAdded, result.Changes[0].ChangeType);
|
||||
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R4_Detects_KevRemoved()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(kev: true);
|
||||
var curr = CreateSnapshot(kev: false);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Equal(MaterialChangeType.KevRemoved, result.Changes[0].ChangeType);
|
||||
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R4_Detects_EpssThresholdCrossing_Up()
|
||||
{
|
||||
// Arrange - EPSS crossing above 0.5 threshold
|
||||
var prev = CreateSnapshot(epssScore: 0.3);
|
||||
var curr = CreateSnapshot(epssScore: 0.7);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Single(result.Changes);
|
||||
Assert.Equal(MaterialChangeType.EpssThreshold, result.Changes[0].ChangeType);
|
||||
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R4_Detects_EpssThresholdCrossing_Down()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(epssScore: 0.7);
|
||||
var curr = CreateSnapshot(epssScore: 0.3);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Equal(MaterialChangeType.EpssThreshold, result.Changes[0].ChangeType);
|
||||
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R4_Ignores_EpssWithinThreshold()
|
||||
{
|
||||
// Arrange - Both below threshold
|
||||
var prev = CreateSnapshot(epssScore: 0.2);
|
||||
var curr = CreateSnapshot(epssScore: 0.4);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasMaterialChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R4_Detects_PolicyFlip_AllowToBlock()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(policyDecision: PolicyDecisionType.Allow);
|
||||
var curr = CreateSnapshot(policyDecision: PolicyDecisionType.Block);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Equal(MaterialChangeType.PolicyFlip, result.Changes[0].ChangeType);
|
||||
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R4_Detects_PolicyFlip_BlockToAllow()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(policyDecision: PolicyDecisionType.Block);
|
||||
var curr = CreateSnapshot(policyDecision: PolicyDecisionType.Allow);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Equal(MaterialChangeType.PolicyFlip, result.Changes[0].ChangeType);
|
||||
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Changes Tests
|
||||
|
||||
[Fact]
|
||||
public void Detects_MultipleChanges()
|
||||
{
|
||||
// Arrange - Multiple rule violations
|
||||
var prev = CreateSnapshot(reachable: false, kev: false);
|
||||
var curr = CreateSnapshot(reachable: true, kev: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasMaterialChange);
|
||||
Assert.Equal(2, result.Changes.Length);
|
||||
Assert.Contains(result.Changes, c => c.Rule == DetectionRule.R1_ReachabilityFlip);
|
||||
Assert.Contains(result.Changes, c => c.ChangeType == MaterialChangeType.KevAdded);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Priority Score Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputesPriorityScore_ForRiskIncrease()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(reachable: false, epssScore: 0.8);
|
||||
var curr = CreateSnapshot(reachable: true, epssScore: 0.8);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.PriorityScore > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputesPriorityScore_ForRiskDecrease()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(reachable: true, epssScore: 0.8);
|
||||
var curr = CreateSnapshot(reachable: false, epssScore: 0.8);
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.PriorityScore < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityScore_ZeroWhenNoChanges()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot();
|
||||
var curr = CreateSnapshot();
|
||||
|
||||
// Act
|
||||
var result = _detector.Compare(prev, curr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result.PriorityScore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State Hash Tests
|
||||
|
||||
[Fact]
|
||||
public void StateHash_DifferentForDifferentStates()
|
||||
{
|
||||
// Arrange
|
||||
var snap1 = CreateSnapshot(reachable: true);
|
||||
var snap2 = CreateSnapshot(reachable: false);
|
||||
|
||||
// Act & Assert
|
||||
Assert.NotEqual(snap1.ComputeStateHash(), snap2.ComputeStateHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateHash_SameForSameState()
|
||||
{
|
||||
// Arrange
|
||||
var snap1 = CreateSnapshot(reachable: true, kev: true);
|
||||
var snap2 = CreateSnapshot(reachable: true, kev: true);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(snap1.ComputeStateHash(), snap2.ComputeStateHash());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void ThrowsOnFindingKeyMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var prev = CreateSnapshot(vulnId: "CVE-2024-1111");
|
||||
var curr = CreateSnapshot(vulnId: "CVE-2024-2222");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => _detector.Compare(prev, curr));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiff.Tests;
|
||||
|
||||
public class ReachabilityGateBridgeTests
|
||||
{
|
||||
#region Lattice State Mapping Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CR", true, 1.0)]
|
||||
[InlineData("CONFIRMED_REACHABLE", true, 1.0)]
|
||||
[InlineData("CU", false, 1.0)]
|
||||
[InlineData("CONFIRMED_UNREACHABLE", false, 1.0)]
|
||||
public void MapLatticeToReachable_ConfirmedStates_HighestConfidence(
|
||||
string latticeState, bool expectedReachable, double expectedConfidence)
|
||||
{
|
||||
// Act
|
||||
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedReachable, reachable);
|
||||
Assert.Equal(expectedConfidence, confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SR", true, 0.85)]
|
||||
[InlineData("STATIC_REACHABLE", true, 0.85)]
|
||||
[InlineData("SU", false, 0.85)]
|
||||
[InlineData("STATIC_UNREACHABLE", false, 0.85)]
|
||||
public void MapLatticeToReachable_StaticStates_HighConfidence(
|
||||
string latticeState, bool expectedReachable, double expectedConfidence)
|
||||
{
|
||||
// Act
|
||||
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedReachable, reachable);
|
||||
Assert.Equal(expectedConfidence, confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RO", true, 0.90)]
|
||||
[InlineData("RUNTIME_OBSERVED", true, 0.90)]
|
||||
[InlineData("RU", false, 0.70)]
|
||||
[InlineData("RUNTIME_UNOBSERVED", false, 0.70)]
|
||||
public void MapLatticeToReachable_RuntimeStates_CorrectConfidence(
|
||||
string latticeState, bool expectedReachable, double expectedConfidence)
|
||||
{
|
||||
// Act
|
||||
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedReachable, reachable);
|
||||
Assert.Equal(expectedConfidence, confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("U")]
|
||||
[InlineData("UNKNOWN")]
|
||||
public void MapLatticeToReachable_UnknownState_NullWithZeroConfidence(string latticeState)
|
||||
{
|
||||
// Act
|
||||
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
|
||||
|
||||
// Assert
|
||||
Assert.Null(reachable);
|
||||
Assert.Equal(0.0, confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("X")]
|
||||
[InlineData("CONTESTED")]
|
||||
public void MapLatticeToReachable_ContestedState_NullWithMediumConfidence(string latticeState)
|
||||
{
|
||||
// Act
|
||||
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
|
||||
|
||||
// Assert
|
||||
Assert.Null(reachable);
|
||||
Assert.Equal(0.5, confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapLatticeToReachable_UnrecognizedState_NullWithZeroConfidence()
|
||||
{
|
||||
// Act
|
||||
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable("INVALID_STATE");
|
||||
|
||||
// Assert
|
||||
Assert.Null(reachable);
|
||||
Assert.Equal(0.0, confidence);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromLatticeState Tests
|
||||
|
||||
[Fact]
|
||||
public void FromLatticeState_CreatesGateWithCorrectValues()
|
||||
{
|
||||
// Act
|
||||
var gate = ReachabilityGateBridge.FromLatticeState("CR", configActivated: true, runningUser: false);
|
||||
|
||||
// Assert
|
||||
Assert.True(gate.Reachable);
|
||||
Assert.True(gate.ConfigActivated);
|
||||
Assert.False(gate.RunningUser);
|
||||
Assert.Equal(1.0, gate.Confidence);
|
||||
Assert.Equal("CR", gate.LatticeState);
|
||||
Assert.Contains("REACHABLE", gate.Rationale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromLatticeState_UnknownState_CreatesGateWithNulls()
|
||||
{
|
||||
// Act
|
||||
var gate = ReachabilityGateBridge.FromLatticeState("U");
|
||||
|
||||
// Assert
|
||||
Assert.Null(gate.Reachable);
|
||||
Assert.Equal(0.0, gate.Confidence);
|
||||
Assert.Contains("UNKNOWN", gate.Rationale);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ComputeClass Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeClass_AllFalse_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var gate = new ReachabilityGate(
|
||||
Reachable: false,
|
||||
ConfigActivated: false,
|
||||
RunningUser: false,
|
||||
Confidence: 1.0,
|
||||
LatticeState: "CU",
|
||||
Rationale: "test");
|
||||
|
||||
// Act
|
||||
var gateClass = gate.ComputeClass();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, gateClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeClass_OnlyReachable_ReturnsOne()
|
||||
{
|
||||
// Arrange
|
||||
var gate = new ReachabilityGate(
|
||||
Reachable: true,
|
||||
ConfigActivated: false,
|
||||
RunningUser: false,
|
||||
Confidence: 1.0,
|
||||
LatticeState: "CR",
|
||||
Rationale: "test");
|
||||
|
||||
// Act
|
||||
var gateClass = gate.ComputeClass();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, gateClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeClass_ReachableAndActivated_ReturnsThree()
|
||||
{
|
||||
// Arrange
|
||||
var gate = new ReachabilityGate(
|
||||
Reachable: true,
|
||||
ConfigActivated: true,
|
||||
RunningUser: false,
|
||||
Confidence: 1.0,
|
||||
LatticeState: "CR",
|
||||
Rationale: "test");
|
||||
|
||||
// Act
|
||||
var gateClass = gate.ComputeClass();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, gateClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeClass_AllTrue_ReturnsSeven()
|
||||
{
|
||||
// Arrange
|
||||
var gate = new ReachabilityGate(
|
||||
Reachable: true,
|
||||
ConfigActivated: true,
|
||||
RunningUser: true,
|
||||
Confidence: 1.0,
|
||||
LatticeState: "CR",
|
||||
Rationale: "test");
|
||||
|
||||
// Act
|
||||
var gateClass = gate.ComputeClass();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(7, gateClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeClass_NullsAsZero()
|
||||
{
|
||||
// Arrange - nulls should be treated as false (0)
|
||||
var gate = new ReachabilityGate(
|
||||
Reachable: null,
|
||||
ConfigActivated: null,
|
||||
RunningUser: null,
|
||||
Confidence: 0.0,
|
||||
LatticeState: "U",
|
||||
Rationale: "test");
|
||||
|
||||
// Act
|
||||
var gateClass = gate.ComputeClass();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, gateClass);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InterpretClass Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "LOW")]
|
||||
[InlineData(7, "HIGH")]
|
||||
public void InterpretClass_ExtremeCases_CorrectRiskLevel(int gateClass, string expectedRiskContains)
|
||||
{
|
||||
// Act
|
||||
var interpretation = ReachabilityGateBridge.InterpretClass(gateClass);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(expectedRiskContains, interpretation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RiskInterpretation_Property_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var gate = new ReachabilityGate(
|
||||
Reachable: true,
|
||||
ConfigActivated: true,
|
||||
RunningUser: true,
|
||||
Confidence: 1.0,
|
||||
LatticeState: "CR",
|
||||
Rationale: "test");
|
||||
|
||||
// Act
|
||||
var interpretation = gate.RiskInterpretation;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("HIGH", interpretation);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static Unknown Gate Tests
|
||||
|
||||
[Fact]
|
||||
public void Unknown_HasExpectedValues()
|
||||
{
|
||||
// Act
|
||||
var gate = ReachabilityGate.Unknown;
|
||||
|
||||
// Assert
|
||||
Assert.Null(gate.Reachable);
|
||||
Assert.Null(gate.ConfigActivated);
|
||||
Assert.Null(gate.RunningUser);
|
||||
Assert.Equal(0.0, gate.Confidence);
|
||||
Assert.Equal("U", gate.LatticeState);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rationale Generation Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CR", "Confirmed reachable")]
|
||||
[InlineData("SR", "Statically reachable")]
|
||||
[InlineData("RO", "Observed at runtime")]
|
||||
[InlineData("U", "unknown")]
|
||||
[InlineData("X", "Contested")]
|
||||
public void GenerateRationale_IncludesStateDescription(string latticeState, string expectedContains)
|
||||
{
|
||||
// Act
|
||||
var rationale = ReachabilityGateBridge.GenerateRationale(latticeState, true);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(expectedContains, rationale, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiff.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden fixture tests for Smart-Diff state comparison determinism.
|
||||
/// Per Sprint 3500.3 - ensures stable, reproducible change detection.
|
||||
/// </summary>
|
||||
public class StateComparisonGoldenTests
|
||||
{
|
||||
private static readonly string FixturePath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures",
|
||||
"state-comparison.v1.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly MaterialRiskChangeDetector _detector;
|
||||
|
||||
public StateComparisonGoldenTests()
|
||||
{
|
||||
_detector = new MaterialRiskChangeDetector();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoldenFixture_Exists()
|
||||
{
|
||||
Assert.True(File.Exists(FixturePath), $"Fixture file not found: {FixturePath}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetTestCases))]
|
||||
public void DetectChanges_MatchesGoldenFixture(GoldenTestCase testCase)
|
||||
{
|
||||
// Arrange
|
||||
var previous = ParseSnapshot(testCase.Previous);
|
||||
var current = ParseSnapshot(testCase.Current);
|
||||
|
||||
// Act
|
||||
var result = _detector.DetectChanges(previous, current);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(testCase.Expected.HasMaterialChange, result.HasMaterialChange);
|
||||
|
||||
if (testCase.Expected.ChangeCount.HasValue)
|
||||
{
|
||||
Assert.Equal(testCase.Expected.ChangeCount.Value, result.Changes.Length);
|
||||
}
|
||||
|
||||
if (testCase.Expected.TotalPriorityScore.HasValue)
|
||||
{
|
||||
Assert.Equal(testCase.Expected.TotalPriorityScore.Value, result.PriorityScore);
|
||||
}
|
||||
|
||||
if (testCase.Expected.ChangeType is not null && result.Changes.Length > 0)
|
||||
{
|
||||
var expectedType = ParseChangeType(testCase.Expected.ChangeType);
|
||||
Assert.Contains(result.Changes, c => c.ChangeType == expectedType);
|
||||
}
|
||||
|
||||
if (testCase.Expected.Direction is not null && result.Changes.Length > 0)
|
||||
{
|
||||
var expectedDirection = ParseDirection(testCase.Expected.Direction);
|
||||
Assert.Contains(result.Changes, c => c.Direction == expectedDirection);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
|
||||
ScanId: "scan-hash-001",
|
||||
CapturedAt: DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
|
||||
Reachable: true,
|
||||
LatticeState: "CR",
|
||||
VexStatus: VexStatusType.Affected,
|
||||
InAffectedRange: true,
|
||||
Kev: false,
|
||||
EpssScore: 0.05,
|
||||
PolicyFlags: ["FLAG_A", "FLAG_B"],
|
||||
PolicyDecision: PolicyDecisionType.Warn);
|
||||
|
||||
// Act - compute hash multiple times
|
||||
var hash1 = snapshot.ComputeStateHash();
|
||||
var hash2 = snapshot.ComputeStateHash();
|
||||
var hash3 = snapshot.ComputeStateHash();
|
||||
|
||||
// Assert - all hashes must be identical
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Equal(hash2, hash3);
|
||||
Assert.StartsWith("sha256:", hash1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateHash_DiffersWithReachabilityChange()
|
||||
{
|
||||
// Arrange
|
||||
var baseSnapshot = new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
|
||||
ScanId: "scan-hash-001",
|
||||
CapturedAt: DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
|
||||
Reachable: true,
|
||||
LatticeState: "CR",
|
||||
VexStatus: VexStatusType.Affected,
|
||||
InAffectedRange: true,
|
||||
Kev: false,
|
||||
EpssScore: 0.05,
|
||||
PolicyFlags: [],
|
||||
PolicyDecision: null);
|
||||
|
||||
var modifiedSnapshot = baseSnapshot with { Reachable = false };
|
||||
|
||||
// Act
|
||||
var hash1 = baseSnapshot.ComputeStateHash();
|
||||
var hash2 = modifiedSnapshot.ComputeStateHash();
|
||||
|
||||
// Assert - hashes must differ
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateHash_DiffersWithVexStatusChange()
|
||||
{
|
||||
// Arrange
|
||||
var baseSnapshot = new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
|
||||
ScanId: "scan-hash-001",
|
||||
CapturedAt: DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
|
||||
Reachable: true,
|
||||
LatticeState: "CR",
|
||||
VexStatus: VexStatusType.Affected,
|
||||
InAffectedRange: true,
|
||||
Kev: false,
|
||||
EpssScore: 0.05,
|
||||
PolicyFlags: [],
|
||||
PolicyDecision: null);
|
||||
|
||||
var modifiedSnapshot = baseSnapshot with { VexStatus = VexStatusType.NotAffected };
|
||||
|
||||
// Act
|
||||
var hash1 = baseSnapshot.ComputeStateHash();
|
||||
var hash2 = modifiedSnapshot.ComputeStateHash();
|
||||
|
||||
// Assert - hashes must differ
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateHash_SameForEquivalentStates()
|
||||
{
|
||||
// Arrange - two snapshots with same risk-relevant fields but different scan IDs
|
||||
var snapshot1 = new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
|
||||
ScanId: "scan-001",
|
||||
CapturedAt: DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
|
||||
Reachable: true,
|
||||
LatticeState: "CR",
|
||||
VexStatus: VexStatusType.Affected,
|
||||
InAffectedRange: true,
|
||||
Kev: false,
|
||||
EpssScore: 0.05,
|
||||
PolicyFlags: [],
|
||||
PolicyDecision: null);
|
||||
|
||||
var snapshot2 = new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
|
||||
ScanId: "scan-002", // Different scan ID
|
||||
CapturedAt: DateTimeOffset.Parse("2024-12-16T10:00:00Z"), // Different timestamp
|
||||
Reachable: true,
|
||||
LatticeState: "CR",
|
||||
VexStatus: VexStatusType.Affected,
|
||||
InAffectedRange: true,
|
||||
Kev: false,
|
||||
EpssScore: 0.05,
|
||||
PolicyFlags: [],
|
||||
PolicyDecision: null);
|
||||
|
||||
// Act
|
||||
var hash1 = snapshot1.ComputeStateHash();
|
||||
var hash2 = snapshot2.ComputeStateHash();
|
||||
|
||||
// Assert - hashes should be the same (scan ID and timestamp are not part of state hash)
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityScore_IsConsistent()
|
||||
{
|
||||
// Arrange - KEV flip should always produce same priority
|
||||
var previous = new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-6666", "pkg:npm/axios@0.21.0"),
|
||||
ScanId: "scan-prev",
|
||||
CapturedAt: DateTimeOffset.Parse("2024-12-01T10:00:00Z"),
|
||||
Reachable: true,
|
||||
LatticeState: "SR",
|
||||
VexStatus: VexStatusType.Affected,
|
||||
InAffectedRange: true,
|
||||
Kev: false,
|
||||
EpssScore: 0.08,
|
||||
PolicyFlags: [],
|
||||
PolicyDecision: null);
|
||||
|
||||
var current = previous with
|
||||
{
|
||||
ScanId = "scan-curr",
|
||||
CapturedAt = DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
|
||||
Kev = true
|
||||
};
|
||||
|
||||
// Act - detect multiple times
|
||||
var result1 = _detector.DetectChanges(previous, current);
|
||||
var result2 = _detector.DetectChanges(previous, current);
|
||||
var result3 = _detector.DetectChanges(previous, current);
|
||||
|
||||
// Assert - priority score should be deterministic
|
||||
Assert.Equal(result1.PriorityScore, result2.PriorityScore);
|
||||
Assert.Equal(result2.PriorityScore, result3.PriorityScore);
|
||||
}
|
||||
|
||||
#region Data Loading
|
||||
|
||||
public static IEnumerable<object[]> GetTestCases()
|
||||
{
|
||||
if (!File.Exists(FixturePath))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(FixturePath);
|
||||
var fixture = JsonSerializer.Deserialize<GoldenFixture>(json, JsonOptions);
|
||||
|
||||
if (fixture?.TestCases is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var testCase in fixture.TestCases)
|
||||
{
|
||||
yield return new object[] { testCase };
|
||||
}
|
||||
}
|
||||
|
||||
private static RiskStateSnapshot ParseSnapshot(SnapshotData data)
|
||||
{
|
||||
return new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey(data.FindingKey.VulnId, data.FindingKey.Purl),
|
||||
ScanId: data.ScanId,
|
||||
CapturedAt: DateTimeOffset.Parse(data.CapturedAt),
|
||||
Reachable: data.Reachable,
|
||||
LatticeState: data.LatticeState,
|
||||
VexStatus: ParseVexStatus(data.VexStatus),
|
||||
InAffectedRange: data.InAffectedRange,
|
||||
Kev: data.Kev,
|
||||
EpssScore: data.EpssScore,
|
||||
PolicyFlags: data.PolicyFlags?.ToImmutableArray() ?? [],
|
||||
PolicyDecision: ParsePolicyDecision(data.PolicyDecision));
|
||||
}
|
||||
|
||||
private static VexStatusType ParseVexStatus(string value)
|
||||
{
|
||||
return value.ToLowerInvariant() switch
|
||||
{
|
||||
"affected" => VexStatusType.Affected,
|
||||
"not_affected" => VexStatusType.NotAffected,
|
||||
"fixed" => VexStatusType.Fixed,
|
||||
"under_investigation" => VexStatusType.UnderInvestigation,
|
||||
_ => VexStatusType.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyDecisionType? ParsePolicyDecision(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return null;
|
||||
|
||||
return value.ToLowerInvariant() switch
|
||||
{
|
||||
"allow" => PolicyDecisionType.Allow,
|
||||
"warn" => PolicyDecisionType.Warn,
|
||||
"block" => PolicyDecisionType.Block,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static MaterialChangeType ParseChangeType(string value)
|
||||
{
|
||||
return value.ToLowerInvariant() switch
|
||||
{
|
||||
"reachability_flip" => MaterialChangeType.ReachabilityFlip,
|
||||
"vex_flip" => MaterialChangeType.VexFlip,
|
||||
"range_boundary" => MaterialChangeType.RangeBoundary,
|
||||
"kev_added" => MaterialChangeType.KevAdded,
|
||||
"kev_removed" => MaterialChangeType.KevRemoved,
|
||||
"epss_threshold" => MaterialChangeType.EpssThreshold,
|
||||
"policy_flip" => MaterialChangeType.PolicyFlip,
|
||||
_ => throw new ArgumentException($"Unknown change type: {value}")
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskDirection ParseDirection(string value)
|
||||
{
|
||||
return value.ToLowerInvariant() switch
|
||||
{
|
||||
"increased" => RiskDirection.Increased,
|
||||
"decreased" => RiskDirection.Decreased,
|
||||
"neutral" => RiskDirection.Neutral,
|
||||
_ => throw new ArgumentException($"Unknown direction: {value}")
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Fixture DTOs
|
||||
|
||||
public class GoldenFixture
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public List<GoldenTestCase>? TestCases { get; set; }
|
||||
}
|
||||
|
||||
public class GoldenTestCase
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string? Rule { get; set; }
|
||||
public SnapshotData Previous { get; set; } = new();
|
||||
public SnapshotData Current { get; set; } = new();
|
||||
public ExpectedResult Expected { get; set; } = new();
|
||||
|
||||
public override string ToString() => $"{Id}: {Name}";
|
||||
}
|
||||
|
||||
public class SnapshotData
|
||||
{
|
||||
public FindingKeyData FindingKey { get; set; } = new();
|
||||
public string ScanId { get; set; } = "";
|
||||
public string CapturedAt { get; set; } = "";
|
||||
public bool? Reachable { get; set; }
|
||||
public string? LatticeState { get; set; }
|
||||
public string VexStatus { get; set; } = "unknown";
|
||||
public bool? InAffectedRange { get; set; }
|
||||
public bool Kev { get; set; }
|
||||
public double? EpssScore { get; set; }
|
||||
public List<string>? PolicyFlags { get; set; }
|
||||
public string? PolicyDecision { get; set; }
|
||||
}
|
||||
|
||||
public class FindingKeyData
|
||||
{
|
||||
public string VulnId { get; set; } = "";
|
||||
public string Purl { get; set; } = "";
|
||||
}
|
||||
|
||||
public class ExpectedResult
|
||||
{
|
||||
public bool HasMaterialChange { get; set; }
|
||||
public string? Direction { get; set; }
|
||||
public string? ChangeType { get; set; }
|
||||
public int? ChangeCount { get; set; }
|
||||
public int? TotalPriorityScore { get; set; }
|
||||
public int? PriorityScoreContribution { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiff.Tests;
|
||||
|
||||
public class VexCandidateEmitterTests
|
||||
{
|
||||
private readonly InMemoryVexCandidateStore _store = new();
|
||||
|
||||
#region Basic Emission Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_WithAbsentApis_EmitsCandidate()
|
||||
{
|
||||
// Arrange
|
||||
var emitter = new VexCandidateEmitter(store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api_1", "vuln_api_2", "safe_api"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", ["safe_api"]); // vuln APIs removed
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api_1", "vuln_api_2"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api_1", "vuln_api_2"])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, result.CandidatesEmitted);
|
||||
Assert.Single(result.Candidates);
|
||||
Assert.Equal(VexStatusType.NotAffected, result.Candidates[0].SuggestedStatus);
|
||||
Assert.Equal(VexJustification.VulnerableCodeNotPresent, result.Candidates[0].Justification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_WithPresentApis_DoesNotEmit()
|
||||
{
|
||||
// Arrange
|
||||
var emitter = new VexCandidateEmitter(store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api_1", "safe_api"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", ["vuln_api_1", "safe_api"]); // vuln API still present
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api_1"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api_1"])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result.CandidatesEmitted);
|
||||
Assert.Empty(result.Candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_FindingAlreadyNotAffected_DoesNotEmit()
|
||||
{
|
||||
// Arrange
|
||||
var emitter = new VexCandidateEmitter(store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api_1"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", []); // API removed
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.NotAffected, // Already not affected
|
||||
VulnerableApis: ["vuln_api_1"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.NotAffected,
|
||||
VulnerableApis: ["vuln_api_1"])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result.CandidatesEmitted);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Call Graph Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_NoCallGraph_DoesNotEmit()
|
||||
{
|
||||
// Arrange
|
||||
var emitter = new VexCandidateEmitter(store: _store);
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api_1"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api_1"])],
|
||||
PreviousCallGraph: null,
|
||||
CurrentCallGraph: null);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result.CandidatesEmitted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_NoVulnerableApis_DoesNotEmit()
|
||||
{
|
||||
// Arrange
|
||||
var emitter = new VexCandidateEmitter(store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["api_1"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: [])], // No vulnerable APIs tracked
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: [])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result.CandidatesEmitted);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Confidence Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_MultipleAbsentApis_HigherConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var emitter = new VexCandidateEmitter(store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_1", "vuln_2", "vuln_3"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", []); // All removed
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_1", "vuln_2", "vuln_3"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_1", "vuln_2", "vuln_3"])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Candidates);
|
||||
Assert.Equal(0.95, result.Candidates[0].Confidence); // 3+ APIs = 0.95
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_BelowConfidenceThreshold_DoesNotEmit()
|
||||
{
|
||||
// Arrange - Set high threshold
|
||||
var options = new VexCandidateEmitterOptions { MinConfidence = 0.99 };
|
||||
var emitter = new VexCandidateEmitter(options: options, store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_1"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_1"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_1"])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert - Single API = 0.75 confidence, below 0.99 threshold
|
||||
Assert.Equal(0, result.CandidatesEmitted);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate Limiting Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_RespectsMaxCandidatesLimit()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexCandidateEmitterOptions { MaxCandidatesPerImage = 2 };
|
||||
var emitter = new VexCandidateEmitter(options: options, store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_1", "vuln_2", "vuln_3"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
|
||||
|
||||
var findings = Enumerable.Range(1, 5).Select(i => new FindingSnapshot(
|
||||
FindingKey: new FindingKey($"CVE-2024-{i}", $"pkg:npm/example{i}@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: [$"vuln_{i}"])).ToList();
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: findings,
|
||||
CurrentFindings: findings,
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.CandidatesEmitted);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Storage Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_StoresCandidates()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexCandidateEmitterOptions { PersistCandidates = true };
|
||||
var emitter = new VexCandidateEmitter(options: options, store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api"])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
var stored = await _store.GetCandidatesAsync("sha256:abc123");
|
||||
Assert.Single(stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_NoPersist_DoesNotStore()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexCandidateEmitterOptions { PersistCandidates = false };
|
||||
var emitter = new VexCandidateEmitter(options: options, store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api"])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert - Candidate emitted but not stored
|
||||
Assert.Equal(1, result.CandidatesEmitted);
|
||||
var stored = await _store.GetCandidatesAsync("sha256:abc123");
|
||||
Assert.Empty(stored);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evidence Link Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCandidates_IncludesEvidenceLinks()
|
||||
{
|
||||
// Arrange
|
||||
var emitter = new VexCandidateEmitter(store: _store);
|
||||
|
||||
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api_1", "vuln_api_2"]);
|
||||
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
|
||||
|
||||
var context = new VexCandidateEmissionContext(
|
||||
PreviousScanId: "scan-001",
|
||||
CurrentScanId: "scan-002",
|
||||
TargetImageDigest: "sha256:abc123",
|
||||
PreviousFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api_1", "vuln_api_2"])],
|
||||
CurrentFindings: [new FindingSnapshot(
|
||||
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
|
||||
VexStatus: VexStatusType.Affected,
|
||||
VulnerableApis: ["vuln_api_1", "vuln_api_2"])],
|
||||
PreviousCallGraph: prevCallGraph,
|
||||
CurrentCallGraph: currCallGraph);
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitCandidatesAsync(context);
|
||||
|
||||
// Assert
|
||||
var candidate = result.Candidates[0];
|
||||
Assert.Contains(candidate.EvidenceLinks, e => e.Type == "callgraph_diff");
|
||||
Assert.Contains(candidate.EvidenceLinks, e => e.Type == "absent_api" && e.Uri.Contains("vuln_api_1"));
|
||||
Assert.Contains(candidate.EvidenceLinks, e => e.Type == "absent_api" && e.Uri.Contains("vuln_api_2"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for Smart-Diff PostgreSQL repositories.
|
||||
/// Per Sprint 3500.3 - SDIFF-DET-026.
|
||||
/// </summary>
|
||||
[Collection("scanner-postgres")]
|
||||
public class SmartDiffRepositoryIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ScannerPostgresFixture _fixture;
|
||||
private PostgresRiskStateRepository _riskStateRepo = null!;
|
||||
private PostgresVexCandidateStore _vexCandidateStore = null!;
|
||||
private PostgresMaterialRiskChangeRepository _changeRepo = null!;
|
||||
|
||||
public SmartDiffRepositoryIntegrationTests(ScannerPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var dataSource = CreateDataSource();
|
||||
var logger = NullLoggerFactory.Instance;
|
||||
|
||||
_riskStateRepo = new PostgresRiskStateRepository(
|
||||
dataSource,
|
||||
logger.CreateLogger<PostgresRiskStateRepository>());
|
||||
|
||||
_vexCandidateStore = new PostgresVexCandidateStore(
|
||||
dataSource,
|
||||
logger.CreateLogger<PostgresVexCandidateStore>());
|
||||
|
||||
_changeRepo = new PostgresMaterialRiskChangeRepository(
|
||||
dataSource,
|
||||
logger.CreateLogger<PostgresMaterialRiskChangeRepository>());
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private ScannerDataSource CreateDataSource()
|
||||
{
|
||||
var options = new ScannerStorageOptions
|
||||
{
|
||||
Postgres = new StellaOps.Infrastructure.Postgres.Options.PostgresOptions
|
||||
{
|
||||
ConnectionString = _fixture.ConnectionString,
|
||||
SchemaName = _fixture.SchemaName
|
||||
}
|
||||
};
|
||||
|
||||
return new ScannerDataSource(
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
NullLoggerFactory.Instance.CreateLogger<ScannerDataSource>());
|
||||
}
|
||||
|
||||
#region RiskStateSnapshot Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshot_ThenRetrieve_ReturnsCorrectData()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateTestSnapshot("CVE-2024-1234", "pkg:npm/lodash@4.17.21", "scan-001");
|
||||
|
||||
// Act
|
||||
await _riskStateRepo.StoreSnapshotAsync(snapshot);
|
||||
var retrieved = await _riskStateRepo.GetLatestSnapshotAsync(snapshot.FindingKey);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(snapshot.FindingKey.VulnId, retrieved.FindingKey.VulnId);
|
||||
Assert.Equal(snapshot.FindingKey.Purl, retrieved.FindingKey.Purl);
|
||||
Assert.Equal(snapshot.Reachable, retrieved.Reachable);
|
||||
Assert.Equal(snapshot.VexStatus, retrieved.VexStatus);
|
||||
Assert.Equal(snapshot.Kev, retrieved.Kev);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreMultipleSnapshots_GetHistory_ReturnsInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var findingKey = new FindingKey("CVE-2024-5678", "pkg:pypi/requests@2.28.0");
|
||||
|
||||
var snapshot1 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-001",
|
||||
capturedAt: DateTimeOffset.UtcNow.AddHours(-2));
|
||||
var snapshot2 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-002",
|
||||
capturedAt: DateTimeOffset.UtcNow.AddHours(-1));
|
||||
var snapshot3 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-003",
|
||||
capturedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
await _riskStateRepo.StoreSnapshotsAsync([snapshot1, snapshot2, snapshot3]);
|
||||
var history = await _riskStateRepo.GetSnapshotHistoryAsync(findingKey, limit: 10);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, history.Count);
|
||||
Assert.Equal("scan-003", history[0].ScanId); // Most recent first
|
||||
Assert.Equal("scan-002", history[1].ScanId);
|
||||
Assert.Equal("scan-001", history[2].ScanId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshotsForScan_ReturnsAllForScan()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = "scan-bulk-001";
|
||||
var snapshot1 = CreateTestSnapshot("CVE-2024-1111", "pkg:npm/a@1.0.0", scanId);
|
||||
var snapshot2 = CreateTestSnapshot("CVE-2024-2222", "pkg:npm/b@2.0.0", scanId);
|
||||
var snapshot3 = CreateTestSnapshot("CVE-2024-3333", "pkg:npm/c@3.0.0", "other-scan");
|
||||
|
||||
await _riskStateRepo.StoreSnapshotsAsync([snapshot1, snapshot2, snapshot3]);
|
||||
|
||||
// Act
|
||||
var results = await _riskStateRepo.GetSnapshotsForScanAsync(scanId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.Equal(scanId, r.ScanId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StateHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateTestSnapshot("CVE-2024-HASH", "pkg:npm/hash-test@1.0.0", "scan-hash");
|
||||
|
||||
// Act
|
||||
await _riskStateRepo.StoreSnapshotAsync(snapshot);
|
||||
var hash1 = snapshot.ComputeStateHash();
|
||||
|
||||
var retrieved = await _riskStateRepo.GetLatestSnapshotAsync(snapshot.FindingKey);
|
||||
var hash2 = retrieved!.ComputeStateHash();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region VexCandidate Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StoreCandidates_ThenRetrieve_ReturnsCorrectData()
|
||||
{
|
||||
// Arrange
|
||||
var candidate = CreateTestCandidate("CVE-2024-VEX1", "pkg:npm/vex-test@1.0.0", "sha256:abc123");
|
||||
|
||||
// Act
|
||||
await _vexCandidateStore.StoreCandidatesAsync([candidate]);
|
||||
var retrieved = await _vexCandidateStore.GetCandidateAsync(candidate.CandidateId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(candidate.CandidateId, retrieved.CandidateId);
|
||||
Assert.Equal(candidate.SuggestedStatus, retrieved.SuggestedStatus);
|
||||
Assert.Equal(candidate.Justification, retrieved.Justification);
|
||||
Assert.Equal(candidate.Confidence, retrieved.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCandidatesForImage_ReturnsFilteredResults()
|
||||
{
|
||||
// Arrange
|
||||
var imageDigest = "sha256:image123";
|
||||
var candidate1 = CreateTestCandidate("CVE-2024-A", "pkg:npm/a@1.0.0", imageDigest);
|
||||
var candidate2 = CreateTestCandidate("CVE-2024-B", "pkg:npm/b@1.0.0", imageDigest);
|
||||
var candidate3 = CreateTestCandidate("CVE-2024-C", "pkg:npm/c@1.0.0", "sha256:other");
|
||||
|
||||
await _vexCandidateStore.StoreCandidatesAsync([candidate1, candidate2, candidate3]);
|
||||
|
||||
// Act
|
||||
var results = await _vexCandidateStore.GetCandidatesAsync(imageDigest);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.Equal(imageDigest, r.ImageDigest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReviewCandidate_UpdatesReviewStatus()
|
||||
{
|
||||
// Arrange
|
||||
var candidate = CreateTestCandidate("CVE-2024-REVIEW", "pkg:npm/review@1.0.0", "sha256:review");
|
||||
await _vexCandidateStore.StoreCandidatesAsync([candidate]);
|
||||
|
||||
var review = new VexCandidateReview(
|
||||
Action: VexReviewAction.Accept,
|
||||
Reviewer: "test-user@example.com",
|
||||
ReviewedAt: DateTimeOffset.UtcNow,
|
||||
Comment: "Verified via manual code review");
|
||||
|
||||
// Act
|
||||
var success = await _vexCandidateStore.ReviewCandidateAsync(candidate.CandidateId, review);
|
||||
var retrieved = await _vexCandidateStore.GetCandidateAsync(candidate.CandidateId);
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.False(retrieved.RequiresReview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReviewCandidate_NonExistent_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var review = new VexCandidateReview(
|
||||
Action: VexReviewAction.Reject,
|
||||
Reviewer: "test@example.com",
|
||||
ReviewedAt: DateTimeOffset.UtcNow,
|
||||
Comment: "Test");
|
||||
|
||||
// Act
|
||||
var success = await _vexCandidateStore.ReviewCandidateAsync("non-existent-id", review);
|
||||
|
||||
// Assert
|
||||
Assert.False(success);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MaterialRiskChange Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StoreChange_ThenRetrieve_ReturnsCorrectData()
|
||||
{
|
||||
// Arrange
|
||||
var change = CreateTestChange("CVE-2024-CHG1", "pkg:npm/change@1.0.0", hasMaterialChange: true);
|
||||
var scanId = "scan-change-001";
|
||||
|
||||
// Act
|
||||
await _changeRepo.StoreChangeAsync(change, scanId);
|
||||
var results = await _changeRepo.GetChangesForScanAsync(scanId);
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(change.FindingKey.VulnId, results[0].FindingKey.VulnId);
|
||||
Assert.Equal(change.HasMaterialChange, results[0].HasMaterialChange);
|
||||
Assert.Equal(change.PriorityScore, results[0].PriorityScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreMultipleChanges_QueryByFinding_ReturnsHistory()
|
||||
{
|
||||
// Arrange
|
||||
var findingKey = new FindingKey("CVE-2024-HIST", "pkg:npm/history@1.0.0");
|
||||
var change1 = CreateTestChange(findingKey.VulnId, findingKey.Purl, hasMaterialChange: true, priority: 100);
|
||||
var change2 = CreateTestChange(findingKey.VulnId, findingKey.Purl, hasMaterialChange: true, priority: 200);
|
||||
|
||||
await _changeRepo.StoreChangeAsync(change1, "scan-h1");
|
||||
await _changeRepo.StoreChangeAsync(change2, "scan-h2");
|
||||
|
||||
// Act
|
||||
var history = await _changeRepo.GetChangesForFindingAsync(findingKey, limit: 10);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, history.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryChanges_WithMinPriority_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var change1 = CreateTestChange("CVE-2024-P1", "pkg:npm/p1@1.0.0", hasMaterialChange: true, priority: 50);
|
||||
var change2 = CreateTestChange("CVE-2024-P2", "pkg:npm/p2@1.0.0", hasMaterialChange: true, priority: 150);
|
||||
var change3 = CreateTestChange("CVE-2024-P3", "pkg:npm/p3@1.0.0", hasMaterialChange: true, priority: 250);
|
||||
|
||||
await _changeRepo.StoreChangesAsync([change1, change2, change3], "scan-priority");
|
||||
|
||||
var query = new MaterialRiskChangeQuery
|
||||
{
|
||||
MinPriorityScore = 100,
|
||||
Offset = 0,
|
||||
Limit = 100
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _changeRepo.QueryChangesAsync(query);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Changes.Length);
|
||||
Assert.All(result.Changes, c => Assert.True(c.PriorityScore >= 100));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Data Factories
|
||||
|
||||
private static RiskStateSnapshot CreateTestSnapshot(
|
||||
string vulnId,
|
||||
string purl,
|
||||
string scanId,
|
||||
DateTimeOffset? capturedAt = null)
|
||||
{
|
||||
return new RiskStateSnapshot(
|
||||
FindingKey: new FindingKey(vulnId, purl),
|
||||
ScanId: scanId,
|
||||
CapturedAt: capturedAt ?? DateTimeOffset.UtcNow,
|
||||
Reachable: true,
|
||||
LatticeState: "CR",
|
||||
VexStatus: VexStatusType.Affected,
|
||||
InAffectedRange: true,
|
||||
Kev: false,
|
||||
EpssScore: 0.05,
|
||||
PolicyFlags: ["TEST_FLAG"],
|
||||
PolicyDecision: PolicyDecisionType.Warn);
|
||||
}
|
||||
|
||||
private static VexCandidate CreateTestCandidate(
|
||||
string vulnId,
|
||||
string purl,
|
||||
string imageDigest)
|
||||
{
|
||||
return new VexCandidate(
|
||||
CandidateId: $"cand-{Guid.NewGuid():N}",
|
||||
FindingKey: new FindingKey(vulnId, purl),
|
||||
SuggestedStatus: VexStatusType.NotAffected,
|
||||
Justification: VexJustification.VulnerableCodeNotInExecutePath,
|
||||
Rationale: "Test rationale - vulnerable code path not executed",
|
||||
EvidenceLinks:
|
||||
[
|
||||
new EvidenceLink("call_graph", "stellaops://graph/test", "sha256:evidence123")
|
||||
],
|
||||
Confidence: 0.85,
|
||||
ImageDigest: imageDigest,
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(30),
|
||||
RequiresReview: true);
|
||||
}
|
||||
|
||||
private static MaterialRiskChangeResult CreateTestChange(
|
||||
string vulnId,
|
||||
string purl,
|
||||
bool hasMaterialChange,
|
||||
int priority = 100)
|
||||
{
|
||||
var changes = hasMaterialChange
|
||||
?
|
||||
[
|
||||
new DetectedChange(
|
||||
Rule: DetectionRule.R1_ReachabilityFlip,
|
||||
ChangeType: MaterialChangeType.ReachabilityFlip,
|
||||
Direction: RiskDirection.Increased,
|
||||
Reason: "Test reachability flip",
|
||||
PreviousValue: "false",
|
||||
CurrentValue: "true",
|
||||
Weight: 1.0)
|
||||
]
|
||||
: ImmutableArray<DetectedChange>.Empty;
|
||||
|
||||
return new MaterialRiskChangeResult(
|
||||
FindingKey: new FindingKey(vulnId, purl),
|
||||
HasMaterialChange: hasMaterialChange,
|
||||
Changes: changes,
|
||||
PriorityScore: priority,
|
||||
PreviousStateHash: "sha256:prev",
|
||||
CurrentStateHash: "sha256:curr");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user