diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/SecurityVulnerabilityContracts.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/SecurityVulnerabilityContracts.cs new file mode 100644 index 000000000..0c11f2953 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/SecurityVulnerabilityContracts.cs @@ -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 References { get; init; } = []; + public IReadOnlyList AffectedVersions { get; init; } = []; + public IReadOnlyList 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 Environments { get; init; } = []; + public required DateTimeOffset FirstSeen { get; init; } + public IReadOnlyList DeployedEnvironments { get; init; } = []; + public IReadOnlyList GateImpacts { get; init; } = []; + public IReadOnlyList 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 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 Factors { get; init; } = []; + public IReadOnlyList 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; } +} diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/SecurityVulnerabilityEndpoints.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/SecurityVulnerabilityEndpoints.cs new file mode 100644 index 000000000..d03c677df --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/SecurityVulnerabilityEndpoints.cs @@ -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, 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(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + } +} diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs index 3e1e6953e..adbd84f73 100644 --- a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs @@ -245,6 +245,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); 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(); diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnerabilityDetailService.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnerabilityDetailService.cs new file mode 100644 index 000000000..7e811610c --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnerabilityDetailService.cs @@ -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 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 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 ResolveProjectionAsync(string tenantId, string identifier, CancellationToken ct) + { + var defaultPolicyVersion = _configuration.GetValue("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 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 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 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 ResolveDeployedEnvironments(JsonObject labels) + { + if (!labels.TryGetPropertyValue("deployedEnvironments", out var node) || node is not JsonArray entries) + { + return []; + } + + return entries + .OfType() + .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 ResolveGateImpacts(JsonObject labels) + { + if (!labels.TryGetPropertyValue("gateImpacts", out var node) || node is not JsonArray entries) + { + return []; + } + + return entries + .OfType() + .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 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); + } +} diff --git a/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/Integration/SecurityVulnerabilityEndpointsIntegrationTests.cs b/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/Integration/SecurityVulnerabilityEndpointsIntegrationTests.cs new file mode 100644 index 000000000..186551e7f --- /dev/null +++ b/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/Integration/SecurityVulnerabilityEndpointsIntegrationTests.cs @@ -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(); + projectionRepository + .Setup(repo => repo.GetAsync("test-tenant", "CVE-2026-1234", It.IsAny(), It.IsAny())) + .ReturnsAsync((FindingProjection?)null); + projectionRepository + .Setup(repo => repo.QueryScoredAsync(It.IsAny(), It.IsAny())) + .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(); + 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(); + projectionRepository + .Setup(repo => repo.GetAsync("test-tenant", "CVE-2026-1234", It.IsAny(), It.IsAny())) + .ReturnsAsync((FindingProjection?)null); + projectionRepository + .Setup(repo => repo.QueryScoredAsync(It.IsAny(), It.IsAny())) + .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(); + 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 CreateFactory( + IFindingProjectionRepository projectionRepository, + IFindingScoringService scoringService) + { + return new FindingsLedgerWebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseEnvironment("Development"); + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(projectionRepository); + services.AddSingleton(scoringService); + }); + }); + } + + private static HttpClient CreateClient(WebApplicationFactory 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(); + scoringService + .Setup(service => service.GetCachedScoreAsync("finding-123", It.IsAny())) + .ReturnsAsync(cachedScore); + scoringService + .Setup(service => service.GetScoreHistoryAsync("finding-123", null, null, 1, null, It.IsAny())) + .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"); + } +}