Add Findings vulnerability detail read model and endpoints

Introduce SecurityVulnerabilityEndpoints, VulnerabilityDetailService,
and supporting contracts for the vulnerability detail page backend.
Includes integration tests for the new endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-06 08:52:58 +03:00
parent 59eca36429
commit 31fac84cab
5 changed files with 734 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
namespace StellaOps.Findings.Ledger.WebService.Contracts;
public sealed record VulnerabilityDetailResponse
{
public required string Id { get; init; }
public required string CveId { get; init; }
public required string FindingId { get; init; }
public required string PackageName { get; init; }
public required string ComponentName { get; init; }
public required string Severity { get; init; }
public decimal Cvss { get; init; }
public bool? Reachable { get; init; }
public decimal? ReachabilityConfidence { get; init; }
public required string VexStatus { get; init; }
public string? VexJustification { get; init; }
public string? Description { get; init; }
public IReadOnlyList<string> References { get; init; } = [];
public IReadOnlyList<string> AffectedVersions { get; init; } = [];
public IReadOnlyList<string> FixedVersions { get; init; } = [];
public string? FixedIn { get; init; }
public string? CvssVector { get; init; }
public decimal? Epss { get; init; }
public bool? ExploitedInWild { get; init; }
public string? ReleaseId { get; init; }
public string? ReleaseVersion { get; init; }
public string? Delta { get; init; }
public IReadOnlyList<string> Environments { get; init; } = [];
public required DateTimeOffset FirstSeen { get; init; }
public IReadOnlyList<DeployedEnvironmentResponse> DeployedEnvironments { get; init; } = [];
public IReadOnlyList<GateImpactResponse> GateImpacts { get; init; } = [];
public IReadOnlyList<string> WitnessPath { get; init; } = [];
public SignedScoreResponse? SignedScore { get; init; }
public string? ProofSubjectId { get; init; }
}
public sealed record DeployedEnvironmentResponse
{
public required string Name { get; init; }
public required string Version { get; init; }
public DateTimeOffset? DeployedAt { get; init; }
public string? ReleaseId { get; init; }
}
public sealed record GateImpactResponse
{
public required string GateType { get; init; }
public required string Impact { get; init; }
public IReadOnlyList<string> AffectedPromotions { get; init; } = [];
}
public sealed record SignedScoreResponse
{
public int Score { get; init; }
public required string PolicyVersion { get; init; }
public required DateTimeOffset ComputedAt { get; init; }
public IReadOnlyList<SignedScoreFactorResponse> Factors { get; init; } = [];
public IReadOnlyList<SignedScoreProvenanceLinkResponse> ProvenanceLinks { get; init; } = [];
public SignedScoreVerifyResponse? Verify { get; init; }
public SignedScoreGateResponse? Gate { get; init; }
}
public sealed record SignedScoreFactorResponse
{
public required string Name { get; init; }
public decimal Weight { get; init; }
public decimal Raw { get; init; }
public decimal Weighted { get; init; }
public required string Source { get; init; }
}
public sealed record SignedScoreProvenanceLinkResponse
{
public required string Label { get; init; }
public required string Href { get; init; }
}
public sealed record SignedScoreVerifyResponse
{
public decimal ReplaySuccessRatio { get; init; }
public int MedianVerifyTimeMs { get; init; }
public int SymbolCoverage { get; init; }
public DateTimeOffset? VerifiedAt { get; init; }
}
public sealed record SignedScoreGateResponse
{
public required string Status { get; init; }
public int Threshold { get; init; }
public int Actual { get; init; }
public required string Reason { get; init; }
}

View File

@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Http.HttpResults;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Findings.Ledger.WebService.Contracts;
using StellaOps.Findings.Ledger.WebService.Services;
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
public static class SecurityVulnerabilityEndpoints
{
public static void MapSecurityVulnerabilityEndpoints(this WebApplication app)
{
app.MapGet("/api/v2/security/vulnerabilities/{identifier}", async Task<Results<Ok<VulnerabilityDetailResponse>, NotFound, ProblemHttpResult>> (
string identifier,
HttpContext httpContext,
IVulnerabilityDetailService service,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(identifier))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_vulnerability_id",
detail: "A vulnerability identifier is required.");
}
if (!StellaOpsTenantResolver.TryResolveTenantId(httpContext, out var tenantId, out _) || string.IsNullOrWhiteSpace(tenantId))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "missing_tenant",
detail: "Tenant resolution failed for the request.");
}
var detail = await service.GetAsync(tenantId, identifier, ct);
return detail is null
? TypedResults.NotFound()
: TypedResults.Ok(detail);
})
.WithName("GetSecurityVulnerabilityDetail")
.WithTags("Security")
.RequireAuthorization("scoring.read")
.RequireTenant()
.Produces<VulnerabilityDetailResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
}
}

View File

@@ -245,6 +245,7 @@ builder.Services.AddSingleton<VexConsensusService>();
builder.Services.AddSingleton<IFindingSummaryBuilder, FindingSummaryBuilder>();
builder.Services.AddSingleton<IFindingRepository, ProjectionBackedFindingRepository>();
builder.Services.AddSingleton<IFindingSummaryService, FindingSummaryService>();
builder.Services.AddSingleton<IVulnerabilityDetailService, VulnerabilityDetailService>();
builder.Services.AddSingleton<IEvidenceRepository, ProjectionBackedEvidenceRepository>();
builder.Services.AddHttpClient("rekor", client =>
{
@@ -1987,6 +1988,7 @@ app.TryRefreshStellaRouterEndpoints(routerEnabled);
// Findings summary, evidence graph, reachability, and runtime timeline endpoints
app.MapFindingSummaryEndpoints();
app.MapSecurityVulnerabilityEndpoints();
app.MapEvidenceGraphEndpoints();
app.MapReachabilityMapEndpoints();
app.MapRuntimeTimelineEndpoints();

View File

@@ -0,0 +1,406 @@
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Services;
using StellaOps.Findings.Ledger.WebService.Contracts;
using System.Text.Json.Nodes;
namespace StellaOps.Findings.Ledger.WebService.Services;
public interface IVulnerabilityDetailService
{
Task<VulnerabilityDetailResponse?> GetAsync(string tenantId, string identifier, CancellationToken ct);
}
public sealed class VulnerabilityDetailService : IVulnerabilityDetailService
{
private readonly IFindingProjectionRepository _projectionRepository;
private readonly IFindingScoringService _scoringService;
private readonly IConfiguration _configuration;
public VulnerabilityDetailService(
IFindingProjectionRepository projectionRepository,
IFindingScoringService scoringService,
IConfiguration configuration)
{
_projectionRepository = projectionRepository;
_scoringService = scoringService;
_configuration = configuration;
}
public async Task<VulnerabilityDetailResponse?> GetAsync(string tenantId, string identifier, CancellationToken ct)
{
var trimmed = identifier.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
var projection = await ResolveProjectionAsync(tenantId, trimmed, ct);
if (projection is null)
{
return null;
}
var labels = projection.Labels;
var findingId = projection.FindingId;
var vulnerabilityId = GetLabelString(labels, "vulnId")
?? GetLabelString(labels, "vulnerability_id")
?? ExtractVulnerabilityId(findingId);
var componentPurl = GetLabelString(labels, "componentPurl")
?? GetLabelString(labels, "component_purl")
?? GetLabelString(labels, "purl")
?? "pkg:unknown/unknown";
var packageName = GetLabelString(labels, "packageName") ?? ExtractPackageName(componentPurl);
var componentVersion = GetLabelString(labels, "version") ?? ExtractVersion(componentPurl) ?? "unknown";
var severity = NormalizeSeverity(projection.RiskSeverity, projection.Severity);
var fixedVersions = GetStringArray(labels, "fixedVersions");
var signedScore = await ResolveSignedScoreAsync(findingId, ct);
return new VulnerabilityDetailResponse
{
Id = vulnerabilityId,
CveId = vulnerabilityId,
FindingId = findingId,
PackageName = packageName,
ComponentName = componentVersion,
Severity = severity,
Cvss = projection.Severity ?? GetLabelDecimal(labels, "cvss") ?? 0m,
Reachable = GetLabelBool(labels, "reachable"),
ReachabilityConfidence = NormalizeProbability(GetLabelDecimal(labels, "reachabilityConfidence")
?? GetLabelDecimal(labels, "reachability_score")),
VexStatus = projection.Status,
VexJustification = GetLabelString(labels, "justification"),
Description = GetLabelString(labels, "description")
?? GetLabelString(labels, "title")
?? $"No detailed description is available for {vulnerabilityId}.",
References = GetStringArray(labels, "references"),
AffectedVersions = GetStringArray(labels, "affectedVersions"),
FixedVersions = fixedVersions,
FixedIn = GetLabelString(labels, "fixedIn") ?? fixedVersions.FirstOrDefault(),
CvssVector = GetLabelString(labels, "cvssVector"),
Epss = NormalizeProbability(GetLabelDecimal(labels, "epss")),
ExploitedInWild = GetLabelBool(labels, "exploitedInWild") ?? GetLabelBool(labels, "kev"),
ReleaseId = GetLabelString(labels, "releaseId"),
ReleaseVersion = GetLabelString(labels, "releaseVersion") ?? GetLabelString(labels, "releaseName"),
Delta = GetLabelString(labels, "delta"),
Environments = ResolveEnvironmentNames(labels),
FirstSeen = projection.UpdatedAt,
DeployedEnvironments = ResolveDeployedEnvironments(labels),
GateImpacts = ResolveGateImpacts(labels),
WitnessPath = GetStringArray(labels, "witnessPath"),
SignedScore = signedScore,
ProofSubjectId = GetLabelString(labels, "proofSubjectId")
};
}
private async Task<FindingProjection?> ResolveProjectionAsync(string tenantId, string identifier, CancellationToken ct)
{
var defaultPolicyVersion = _configuration.GetValue<string>("findings:ledger:defaultPolicyVersion") ?? "1.0.0";
var direct = await _projectionRepository.GetAsync(tenantId, identifier, defaultPolicyVersion, ct);
if (direct is not null)
{
return direct;
}
var (projections, _) = await _projectionRepository.QueryScoredAsync(new ScoredFindingsQuery
{
TenantId = tenantId,
Limit = 250,
SortBy = ScoredFindingsSortField.UpdatedAt,
Descending = true
}, ct);
return projections.FirstOrDefault(projection => MatchesIdentifier(identifier, projection));
}
private async Task<SignedScoreResponse?> ResolveSignedScoreAsync(string findingId, CancellationToken ct)
{
var cached = await _scoringService.GetCachedScoreAsync(findingId, ct);
if (cached is not null)
{
return new SignedScoreResponse
{
Score = cached.Score,
PolicyVersion = cached.PolicyDigest,
ComputedAt = cached.CalculatedAt,
Factors = BuildFactorBreakdown(cached),
ProvenanceLinks =
[
new SignedScoreProvenanceLinkResponse
{
Label = "Score history",
Href = $"/api/v1/findings/{Uri.EscapeDataString(findingId)}/score-history"
},
new SignedScoreProvenanceLinkResponse
{
Label = "Scoring policy",
Href = "/api/v1/scoring/policy"
}
]
};
}
var history = await _scoringService.GetScoreHistoryAsync(findingId, null, null, 1, null, ct);
var latest = history?.History.OrderByDescending(entry => entry.CalculatedAt).FirstOrDefault();
if (latest is null)
{
return null;
}
return new SignedScoreResponse
{
Score = latest.Score,
PolicyVersion = latest.PolicyDigest,
ComputedAt = latest.CalculatedAt,
ProvenanceLinks =
[
new SignedScoreProvenanceLinkResponse
{
Label = "Score history",
Href = $"/api/v1/findings/{Uri.EscapeDataString(findingId)}/score-history"
}
]
};
}
private static IReadOnlyList<SignedScoreFactorResponse> BuildFactorBreakdown(EvidenceWeightedScoreResponse score)
{
if (score.Inputs is null || score.Weights is null)
{
return [];
}
return
[
CreateFactor("Reachability", "reachability", (decimal)score.Weights.Reachability, (decimal)score.Inputs.Reachability),
CreateFactor("Runtime", "runtime", (decimal)score.Weights.Runtime, (decimal)score.Inputs.Runtime),
CreateFactor("Backport", "backport", (decimal)score.Weights.Backport, (decimal)score.Inputs.Backport),
CreateFactor("Exploitability", "exploit", (decimal)score.Weights.Exploit, (decimal)score.Inputs.Exploit),
CreateFactor("Source trust", "source", (decimal)score.Weights.SourceTrust, (decimal)score.Inputs.SourceTrust),
CreateFactor("Mitigation", "mitigation", (decimal)score.Weights.Mitigation, (decimal)score.Inputs.Mitigation)
];
}
private static SignedScoreFactorResponse CreateFactor(string name, string source, decimal weight, decimal raw)
=> new()
{
Name = name,
Source = source,
Weight = weight,
Raw = raw,
Weighted = decimal.Round(weight * raw, 4)
};
private static bool MatchesIdentifier(string identifier, FindingProjection projection)
{
if (projection.FindingId.Equals(identifier, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var vulnerabilityId = GetLabelString(projection.Labels, "vulnId")
?? GetLabelString(projection.Labels, "vulnerability_id")
?? ExtractVulnerabilityId(projection.FindingId);
return vulnerabilityId.Equals(identifier, StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeSeverity(string? severity, decimal? cvss)
{
if (!string.IsNullOrWhiteSpace(severity))
{
return severity.ToUpperInvariant() switch
{
"CRITICAL" => "CRITICAL",
"HIGH" => "HIGH",
"MEDIUM" => "MEDIUM",
"LOW" => "LOW",
_ => severity.ToUpperInvariant()
};
}
if (!cvss.HasValue)
{
return "UNKNOWN";
}
return cvss.Value switch
{
>= 9m => "CRITICAL",
>= 7m => "HIGH",
>= 4m => "MEDIUM",
> 0m => "LOW",
_ => "UNKNOWN"
};
}
private static IReadOnlyList<string> ResolveEnvironmentNames(JsonObject labels)
{
var values = GetStringArray(labels, "environments");
if (values.Count > 0)
{
return values;
}
var single = GetLabelString(labels, "environment");
return string.IsNullOrWhiteSpace(single) ? [] : [single];
}
private static IReadOnlyList<DeployedEnvironmentResponse> ResolveDeployedEnvironments(JsonObject labels)
{
if (!labels.TryGetPropertyValue("deployedEnvironments", out var node) || node is not JsonArray entries)
{
return [];
}
return entries
.OfType<JsonObject>()
.Select(entry => new DeployedEnvironmentResponse
{
Name = GetNodeString(entry, "name") ?? GetNodeString(entry, "environment") ?? "unknown",
Version = GetNodeString(entry, "version") ?? GetNodeString(entry, "releaseVersion") ?? "unknown",
ReleaseId = GetNodeString(entry, "releaseId"),
DeployedAt = GetNodeDate(entry, "deployedAt")
})
.ToArray();
}
private static IReadOnlyList<GateImpactResponse> ResolveGateImpacts(JsonObject labels)
{
if (!labels.TryGetPropertyValue("gateImpacts", out var node) || node is not JsonArray entries)
{
return [];
}
return entries
.OfType<JsonObject>()
.Select(entry => new GateImpactResponse
{
GateType = GetNodeString(entry, "gateType") ?? GetNodeString(entry, "name") ?? "Policy Gate",
Impact = (GetNodeString(entry, "impact") ?? "WARNS").ToUpperInvariant(),
AffectedPromotions = entry["affectedPromotions"] is JsonArray promotions
? promotions.Select(item => item?.ToString() ?? string.Empty).Where(item => item.Length > 0).ToArray()
: []
})
.ToArray();
}
private static string ExtractVulnerabilityId(string findingId)
{
var parts = findingId.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length > 2 ? parts[2] : findingId;
}
private static string ExtractPackageName(string purl)
{
var segment = purl.Split('/').LastOrDefault() ?? purl;
return segment.Split('@').FirstOrDefault() ?? segment;
}
private static string? ExtractVersion(string purl)
{
var segment = purl.Split('/').LastOrDefault();
if (segment is null || !segment.Contains('@', StringComparison.Ordinal))
{
return null;
}
return segment.Split('@').Skip(1).FirstOrDefault();
}
private static string? GetLabelString(JsonObject labels, string key)
{
if (!labels.TryGetPropertyValue(key, out var node) || node is null)
{
return null;
}
return node is JsonValue value && value.TryGetValue(out string? result)
? string.IsNullOrWhiteSpace(result) ? null : result
: node.ToString();
}
private static decimal? GetLabelDecimal(JsonObject labels, string key)
{
if (!labels.TryGetPropertyValue(key, out var node) || node is null)
{
return null;
}
if (node is JsonValue value)
{
if (value.TryGetValue(out decimal decimalValue))
{
return decimalValue;
}
if (value.TryGetValue(out double doubleValue))
{
return Convert.ToDecimal(doubleValue);
}
}
return decimal.TryParse(node.ToString(), out var parsed) ? parsed : null;
}
private static bool? GetLabelBool(JsonObject labels, string key)
{
if (!labels.TryGetPropertyValue(key, out var node) || node is null)
{
return null;
}
if (node is JsonValue value)
{
if (value.TryGetValue(out bool boolValue))
{
return boolValue;
}
if (value.TryGetValue(out string? stringValue) && bool.TryParse(stringValue, out var parsed))
{
return parsed;
}
}
return bool.TryParse(node.ToString(), out var fallback) ? fallback : null;
}
private static IReadOnlyList<string> GetStringArray(JsonObject labels, string key)
{
if (!labels.TryGetPropertyValue(key, out var node) || node is not JsonArray entries)
{
return [];
}
return entries
.Select(item => item?.ToString() ?? string.Empty)
.Where(item => item.Length > 0)
.ToArray();
}
private static string? GetNodeString(JsonObject node, string key)
=> node.TryGetPropertyValue(key, out var value) && value is not null
? value.ToString()
: null;
private static DateTimeOffset? GetNodeDate(JsonObject node, string key)
=> node.TryGetPropertyValue(key, out var value) && value is not null && DateTimeOffset.TryParse(value.ToString(), out var parsed)
? parsed
: null;
private static decimal? NormalizeProbability(decimal? value)
{
if (!value.HasValue)
{
return null;
}
var normalized = value.Value;
if (normalized > 1m && normalized <= 100m)
{
normalized /= 100m;
}
return decimal.Round(decimal.Clamp(normalized, 0m, 1m), 4);
}
}

View File

@@ -0,0 +1,188 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Moq;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Services;
using StellaOps.Findings.Ledger.WebService.Contracts;
using StellaOps.Findings.Ledger.WebService.Services;
using LedgerProgram = StellaOps.Findings.Ledger.WebService.Program;
namespace StellaOps.Findings.Ledger.Tests.Integration;
[Trait("Category", "Integration")]
public sealed class SecurityVulnerabilityEndpointsIntegrationTests
{
[Fact]
public async Task GetSecurityVulnerabilityDetail_ReturnsProjectionBackedFieldsWithoutFabricatedSections()
{
var projection = CreateProjection();
var projectionRepository = new Mock<IFindingProjectionRepository>();
projectionRepository
.Setup(repo => repo.GetAsync("test-tenant", "CVE-2026-1234", It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FindingProjection?)null);
projectionRepository
.Setup(repo => repo.QueryScoredAsync(It.IsAny<ScoredFindingsQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((new[] { projection }, 1));
var scoringService = CreateScoringService(cachedScore: null, history: null);
using var factory = CreateFactory(projectionRepository.Object, scoringService);
using var client = CreateClient(factory);
var response = await client.GetAsync("/api/v2/security/vulnerabilities/CVE-2026-1234");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var detail = await response.Content.ReadFromJsonAsync<VulnerabilityDetailResponse>();
detail.Should().NotBeNull();
detail!.CveId.Should().Be("CVE-2026-1234");
detail.PackageName.Should().Be("openssl");
detail.ComponentName.Should().Be("3.0.8");
detail.SignedScore.Should().BeNull();
detail.GateImpacts.Should().BeEmpty();
detail.WitnessPath.Should().BeEmpty();
detail.ProofSubjectId.Should().Be("finding-123");
}
[Fact]
public async Task GetSecurityVulnerabilityDetail_IncludesSignedScoreOnlyWhenScoringServiceProvidesIt()
{
var projection = CreateProjection();
var projectionRepository = new Mock<IFindingProjectionRepository>();
projectionRepository
.Setup(repo => repo.GetAsync("test-tenant", "CVE-2026-1234", It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FindingProjection?)null);
projectionRepository
.Setup(repo => repo.QueryScoredAsync(It.IsAny<ScoredFindingsQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((new[] { projection }, 1));
var cachedScore = new EvidenceWeightedScoreResponse
{
FindingId = projection.FindingId,
Score = 74,
Bucket = "ActNow",
PolicyDigest = "policy.v1",
CalculatedAt = new DateTimeOffset(2026, 04, 04, 12, 00, 00, TimeSpan.Zero),
Inputs = new EvidenceInputsDto
{
Reachability = 0.92,
Runtime = 0.40,
Backport = 0.10,
Exploit = 0.81,
SourceTrust = 0.55,
Mitigation = 0.20
},
Weights = new EvidenceWeightsDto
{
Reachability = 0.35,
Runtime = 0.10,
Backport = 0.10,
Exploit = 0.20,
SourceTrust = 0.10,
Mitigation = 0.15
}
};
var scoringService = CreateScoringService(cachedScore, history: null);
using var factory = CreateFactory(projectionRepository.Object, scoringService);
using var client = CreateClient(factory);
var response = await client.GetAsync("/api/v2/security/vulnerabilities/CVE-2026-1234");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var detail = await response.Content.ReadFromJsonAsync<VulnerabilityDetailResponse>();
detail.Should().NotBeNull();
detail!.SignedScore.Should().NotBeNull();
detail.SignedScore!.Score.Should().Be(74);
detail.SignedScore.PolicyVersion.Should().Be("policy.v1");
detail.SignedScore.Factors.Should().HaveCount(6);
detail.SignedScore.Gate.Should().BeNull();
detail.SignedScore.Verify.Should().BeNull();
}
private static WebApplicationFactory<LedgerProgram> CreateFactory(
IFindingProjectionRepository projectionRepository,
IFindingScoringService scoringService)
{
return new FindingsLedgerWebApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IFindingProjectionRepository>();
services.RemoveAll<IFindingScoringService>();
services.AddSingleton(projectionRepository);
services.AddSingleton(scoringService);
});
});
}
private static HttpClient CreateClient(WebApplicationFactory<LedgerProgram> factory)
{
var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
client.DefaultRequestHeaders.Add("X-Scopes", "findings:read findings:write");
return client;
}
private static IFindingScoringService CreateScoringService(
EvidenceWeightedScoreResponse? cachedScore,
ScoreHistoryResponse? history)
{
var scoringService = new Mock<IFindingScoringService>();
scoringService
.Setup(service => service.GetCachedScoreAsync("finding-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(cachedScore);
scoringService
.Setup(service => service.GetScoreHistoryAsync("finding-123", null, null, 1, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(history);
return scoringService.Object;
}
private static FindingProjection CreateProjection()
{
return new FindingProjection(
TenantId: "test-tenant",
FindingId: "finding-123",
PolicyVersion: "policy.v1",
Status: "affected",
Severity: 9.1m,
RiskScore: null,
RiskSeverity: "CRITICAL",
RiskProfileVersion: null,
RiskExplanationId: null,
RiskEventSequence: null,
Labels: new JsonObject
{
["vulnId"] = "CVE-2026-1234",
["packageName"] = "openssl",
["version"] = "3.0.8",
["description"] = "Projection-backed vulnerability detail.",
["references"] = new JsonArray("https://nvd.nist.gov/vuln/detail/CVE-2026-1234"),
["affectedVersions"] = new JsonArray("< 3.0.9"),
["fixedVersions"] = new JsonArray("3.0.9"),
["epss"] = 0.87m,
["reachable"] = true,
["proofSubjectId"] = "finding-123",
["environments"] = new JsonArray("prod")
},
CurrentEventId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
ExplainRef: null,
PolicyRationale: new JsonArray(),
UpdatedAt: new DateTimeOffset(2026, 04, 04, 11, 00, 00, TimeSpan.Zero),
CycleHash: "cycle-123");
}
}