feat: add Attestation Chain and Triage Evidence API clients and models
- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains. - Created models for Attestation Chain, including DSSE envelope structures and verification results. - Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component. - Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence. - Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CallGraphSyncService"/>.
|
||||
/// </summary>
|
||||
public sealed class CallGraphSyncServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SyncAsync_WithValidDocument_ReturnsSuccessResult()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryCallGraphProjectionRepository();
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
var scanId = Guid.NewGuid();
|
||||
var document = CreateSampleDocument();
|
||||
|
||||
// Act
|
||||
var result = await service.SyncAsync(scanId, "sha256:test-digest", document);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(scanId, result.ScanId);
|
||||
Assert.Equal(3, result.NodesProjected);
|
||||
Assert.Equal(2, result.EdgesProjected);
|
||||
Assert.Equal(1, result.EntrypointsProjected);
|
||||
Assert.True(result.WasUpdated);
|
||||
Assert.True(result.DurationMs >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAsync_ProjectsToRepository()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryCallGraphProjectionRepository();
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
var scanId = Guid.NewGuid();
|
||||
var document = CreateSampleDocument();
|
||||
|
||||
// Act
|
||||
await service.SyncAsync(scanId, "sha256:test-digest", document);
|
||||
|
||||
// Assert - check repository state
|
||||
Assert.Single(repository.Scans);
|
||||
Assert.Equal(3, repository.Nodes.Count);
|
||||
Assert.Equal(2, repository.Edges.Count);
|
||||
Assert.Single(repository.Entrypoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAsync_SetsScanStatusToCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryCallGraphProjectionRepository();
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
var scanId = Guid.NewGuid();
|
||||
var document = CreateSampleDocument();
|
||||
|
||||
// Act
|
||||
await service.SyncAsync(scanId, "sha256:test-digest", document);
|
||||
|
||||
// Assert
|
||||
Assert.True(repository.Scans.ContainsKey(scanId));
|
||||
Assert.Equal("completed", repository.Scans[scanId].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAsync_WithEmptyDocument_ReturnsZeroCounts()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryCallGraphProjectionRepository();
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
var scanId = Guid.NewGuid();
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Language = "csharp",
|
||||
GraphHash = "test-hash",
|
||||
Nodes = new List<CallgraphNode>(),
|
||||
Edges = new List<CallgraphEdge>(),
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.SyncAsync(scanId, "sha256:test-digest", document);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result.NodesProjected);
|
||||
Assert.Equal(0, result.EdgesProjected);
|
||||
Assert.Equal(0, result.EntrypointsProjected);
|
||||
Assert.False(result.WasUpdated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAsync_WithNullDocument_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryCallGraphProjectionRepository();
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
service.SyncAsync(Guid.NewGuid(), "sha256:test-digest", null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAsync_WithEmptyArtifactDigest_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryCallGraphProjectionRepository();
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
var document = CreateSampleDocument();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.SyncAsync(Guid.NewGuid(), "", document));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByScanAsync_RemovesScanFromRepository()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryCallGraphProjectionRepository();
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
var scanId = Guid.NewGuid();
|
||||
var document = CreateSampleDocument();
|
||||
await service.SyncAsync(scanId, "sha256:test-digest", document);
|
||||
|
||||
// Act
|
||||
await service.DeleteByScanAsync(scanId);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(repository.Scans);
|
||||
Assert.Empty(repository.Nodes);
|
||||
Assert.Empty(repository.Edges);
|
||||
Assert.Empty(repository.Entrypoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAsync_OrdersNodesAndEdgesDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new TrackingProjectionRepository();
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
var scanId = Guid.NewGuid();
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Language = "csharp",
|
||||
GraphHash = "test-hash",
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "z-node", Name = "Last" },
|
||||
new() { Id = "a-node", Name = "First" },
|
||||
new() { Id = "m-node", Name = "Middle" }
|
||||
},
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "z-node", TargetId = "a-node" },
|
||||
new() { SourceId = "a-node", TargetId = "m-node" }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.SyncAsync(scanId, "sha256:test-digest", document);
|
||||
|
||||
// Assert - nodes should be processed in sorted order by Id
|
||||
Assert.Equal(3, repository.ProjectedNodes.Count);
|
||||
Assert.Equal("a-node", repository.ProjectedNodes[0].Id);
|
||||
Assert.Equal("m-node", repository.ProjectedNodes[1].Id);
|
||||
Assert.Equal("z-node", repository.ProjectedNodes[2].Id);
|
||||
}
|
||||
|
||||
private static CallgraphDocument CreateSampleDocument()
|
||||
{
|
||||
return new CallgraphDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Language = "csharp",
|
||||
GraphHash = "sha256:sample-graph-hash",
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node-1", Name = "Main", Kind = "method", Namespace = "Program", Visibility = SymbolVisibility.Public, IsEntrypointCandidate = true },
|
||||
new() { Id = "node-2", Name = "DoWork", Kind = "method", Namespace = "Service", Visibility = SymbolVisibility.Internal },
|
||||
new() { Id = "node-3", Name = "ProcessData", Kind = "method", Namespace = "Core", Visibility = SymbolVisibility.Private }
|
||||
},
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "node-1", TargetId = "node-2", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 },
|
||||
new() { SourceId = "node-2", TargetId = "node-3", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>
|
||||
{
|
||||
new() { NodeId = "node-1", Kind = EntrypointKind.Main, Phase = EntrypointPhase.AppStart, Order = 0 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test repository that tracks the order of projected nodes.
|
||||
/// </summary>
|
||||
private sealed class TrackingProjectionRepository : ICallGraphProjectionRepository
|
||||
{
|
||||
public List<CallgraphNode> ProjectedNodes { get; } = new();
|
||||
|
||||
public Task<bool> UpsertScanAsync(Guid scanId, string artifactDigest, string? sbomDigest = null, string? repoUri = null, string? commitSha = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<int> UpsertNodesAsync(Guid scanId, IReadOnlyList<CallgraphNode> nodes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Store in the order received - the service should have sorted them
|
||||
ProjectedNodes.AddRange(nodes);
|
||||
return Task.FromResult(nodes.Count);
|
||||
}
|
||||
|
||||
public Task<int> UpsertEdgesAsync(Guid scanId, IReadOnlyList<CallgraphEdge> edges, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(edges.Count);
|
||||
|
||||
public Task<int> UpsertEntrypointsAsync(Guid scanId, IReadOnlyList<CallgraphEntrypoint> entrypoints, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(entrypoints.Count);
|
||||
|
||||
public Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,14 @@ public class CallgraphIngestionServiceTests
|
||||
var resolver = new StubParserResolver(parser);
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SignalsOptions());
|
||||
var reachabilityStore = new InMemoryReachabilityStoreRepository(_timeProvider);
|
||||
var callGraphSyncService = new StubCallGraphSyncService();
|
||||
var service = new CallgraphIngestionService(
|
||||
resolver,
|
||||
_artifactStore,
|
||||
_repository,
|
||||
reachabilityStore,
|
||||
_normalizer,
|
||||
callGraphSyncService,
|
||||
options,
|
||||
_timeProvider,
|
||||
NullLogger<CallgraphIngestionService>.Instance);
|
||||
@@ -189,4 +191,33 @@ public class CallgraphIngestionServiceTests
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubCallGraphSyncService : ICallGraphSyncService
|
||||
{
|
||||
public CallGraphSyncResult? LastSyncResult { get; private set; }
|
||||
public CallgraphDocument? LastSyncedDocument { get; private set; }
|
||||
|
||||
public Task<CallGraphSyncResult> SyncAsync(
|
||||
Guid scanId,
|
||||
string artifactDigest,
|
||||
CallgraphDocument document,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastSyncedDocument = document;
|
||||
var result = new CallGraphSyncResult(
|
||||
ScanId: scanId,
|
||||
NodesProjected: document.Nodes.Count,
|
||||
EdgesProjected: document.Edges.Count,
|
||||
EntrypointsProjected: document.Entrypoints.Count,
|
||||
WasUpdated: true,
|
||||
DurationMs: 1);
|
||||
LastSyncResult = result;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScoreExplanationServiceTests.cs
|
||||
// Sprint: SPRINT_3800_0001_0002_score_explanation_service
|
||||
// Description: Unit tests for ScoreExplanationService.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public class ScoreExplanationServiceTests
|
||||
{
|
||||
private readonly ScoreExplanationService _service;
|
||||
private readonly SignalsScoringOptions _options;
|
||||
|
||||
public ScoreExplanationServiceTests()
|
||||
{
|
||||
_options = new SignalsScoringOptions();
|
||||
_service = new ScoreExplanationService(
|
||||
Options.Create(_options),
|
||||
NullLogger<ScoreExplanationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_WithCvssOnly_ReturnsCorrectContribution()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CveId = "CVE-2021-44228",
|
||||
CvssScore = 10.0
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
Assert.Equal("stellaops_risk_v1", result.Kind);
|
||||
Assert.Single(result.Contributions);
|
||||
|
||||
var cvssContrib = result.Contributions[0];
|
||||
Assert.Equal(ScoreFactors.CvssBase, cvssContrib.Factor);
|
||||
Assert.Equal(10.0, cvssContrib.RawValue);
|
||||
Assert.Equal(50.0, cvssContrib.Contribution); // 10.0 * 5.0 default multiplier
|
||||
Assert.Equal(50.0, result.RiskScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_WithEpss_ReturnsCorrectContribution()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CveId = "CVE-2023-12345",
|
||||
EpssScore = 0.5 // 50% probability
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
var epssContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.Epss);
|
||||
Assert.Equal(0.5, epssContrib.RawValue);
|
||||
Assert.Equal(5.0, epssContrib.Contribution); // 0.5 * 10.0 default multiplier
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("entrypoint", 25.0)]
|
||||
[InlineData("direct", 20.0)]
|
||||
[InlineData("runtime", 22.0)]
|
||||
[InlineData("unknown", 12.0)]
|
||||
[InlineData("unreachable", 0.0)]
|
||||
public void ComputeExplanation_WithReachabilityBucket_ReturnsCorrectContribution(
|
||||
string bucket, double expectedContribution)
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
ReachabilityBucket = bucket
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
var reachContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.Reachability);
|
||||
Assert.Equal(expectedContribution, reachContrib.Contribution);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http", 15.0)]
|
||||
[InlineData("https", 15.0)]
|
||||
[InlineData("http_handler", 15.0)]
|
||||
[InlineData("grpc", 12.0)]
|
||||
[InlineData("cli", 3.0)]
|
||||
[InlineData("internal", 5.0)]
|
||||
public void ComputeExplanation_WithEntrypointType_ReturnsCorrectExposure(
|
||||
string entrypointType, double expectedContribution)
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
EntrypointType = entrypointType
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
var exposureContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.ExposureSurface);
|
||||
Assert.Equal(expectedContribution, exposureContrib.Contribution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_WithAuthGate_AppliesDiscount()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 8.0,
|
||||
Gates = new[] { "auth_required" }
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
var gateContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.GateMultiplier);
|
||||
Assert.Equal(-3.0, gateContrib.Contribution); // Default auth discount
|
||||
Assert.Equal(37.0, result.RiskScore); // 8.0 * 5.0 - 3.0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_WithMultipleGates_CombinesDiscounts()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 10.0,
|
||||
Gates = new[] { "auth_required", "admin_role", "feature_flag" }
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
var gateContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.GateMultiplier);
|
||||
// auth: -3, admin: -5, feature_flag: -2 = -10 total
|
||||
Assert.Equal(-10.0, gateContrib.Contribution);
|
||||
Assert.Equal(40.0, result.RiskScore); // 50 - 10
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_WithKev_AppliesBonus()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 7.0,
|
||||
IsKnownExploited = true
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
var kevContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.KnownExploitation);
|
||||
Assert.Equal(10.0, kevContrib.Contribution);
|
||||
Assert.Equal(45.0, result.RiskScore); // 7.0 * 5.0 + 10.0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_WithVexNotAffected_ReducesScore()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 10.0,
|
||||
VexStatus = "not_affected"
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
Assert.NotNull(result.Modifiers);
|
||||
Assert.Contains(result.Modifiers, m => m.Type == "vex_reduction");
|
||||
Assert.True(result.RiskScore < 50.0); // Should be significantly reduced
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_ClampsToMaxScore()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 10.0,
|
||||
EpssScore = 0.95,
|
||||
ReachabilityBucket = "entrypoint",
|
||||
EntrypointType = "http",
|
||||
IsKnownExploited = true
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
Assert.Equal(100.0, result.RiskScore); // Clamped to max
|
||||
Assert.NotNull(result.Modifiers);
|
||||
Assert.Contains(result.Modifiers, m => m.Type == "cap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_ContributionsSumToTotal()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 8.5,
|
||||
EpssScore = 0.3,
|
||||
ReachabilityBucket = "direct",
|
||||
EntrypointType = "grpc"
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
var expectedSum = result.Contributions.Sum(c => c.Contribution);
|
||||
Assert.Equal(expectedSum, result.RiskScore, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_GeneratesSummary()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 9.8,
|
||||
ReachabilityBucket = "entrypoint"
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
Assert.NotNull(result.Summary);
|
||||
Assert.Contains("risk", result.Summary, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_SetsAlgorithmVersion()
|
||||
{
|
||||
var request = new ScoreExplanationRequest { CvssScore = 5.0 };
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
Assert.Equal("1.0.0", result.AlgorithmVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_PreservesEvidenceRef()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 5.0,
|
||||
EvidenceRef = "scan:abc123"
|
||||
};
|
||||
|
||||
var result = _service.ComputeExplanation(request);
|
||||
|
||||
Assert.Equal("scan:abc123", result.EvidenceRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeExplanationAsync_ReturnsSameAsSync()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 7.5,
|
||||
ReachabilityBucket = "runtime"
|
||||
};
|
||||
|
||||
var syncResult = _service.ComputeExplanation(request);
|
||||
var asyncResult = await _service.ComputeExplanationAsync(request);
|
||||
|
||||
Assert.Equal(syncResult.RiskScore, asyncResult.RiskScore);
|
||||
Assert.Equal(syncResult.Contributions.Count, asyncResult.Contributions.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExplanation_IsDeterministic()
|
||||
{
|
||||
var request = new ScoreExplanationRequest
|
||||
{
|
||||
CvssScore = 8.0,
|
||||
EpssScore = 0.4,
|
||||
ReachabilityBucket = "entrypoint",
|
||||
EntrypointType = "http",
|
||||
Gates = new[] { "auth_required" }
|
||||
};
|
||||
|
||||
var result1 = _service.ComputeExplanation(request);
|
||||
var result2 = _service.ComputeExplanation(request);
|
||||
|
||||
Assert.Equal(result1.RiskScore, result2.RiskScore);
|
||||
Assert.Equal(result1.Contributions.Count, result2.Contributions.Count);
|
||||
|
||||
for (int i = 0; i < result1.Contributions.Count; i++)
|
||||
{
|
||||
Assert.Equal(result1.Contributions[i].Factor, result2.Contributions[i].Factor);
|
||||
Assert.Equal(result1.Contributions[i].Contribution, result2.Contributions[i].Contribution);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user