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:
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user