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:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}