Repair triage artifact scope and evidence contracts
This commit is contained in:
@@ -138,6 +138,27 @@ public sealed class TriageController : ControllerBase
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get gated buckets summary for an artifact workspace.
|
||||
/// </summary>
|
||||
/// <param name="artifactId">Artifact identifier from the workspace route.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <response code="200">Summary retrieved.</response>
|
||||
[HttpGet("artifacts/{artifactId}/gated-buckets")]
|
||||
[ProducesResponseType(typeof(GatedBucketsSummaryDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetArtifactGatedBucketsSummaryAsync(
|
||||
[FromRoute] string artifactId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Getting gated buckets summary for artifact {ArtifactId}", artifactId);
|
||||
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var summary = await _gatingService.GetArtifactGatedBucketsSummaryAsync(tenantId, artifactId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get unified evidence package for a finding.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/vulnerabilities")]
|
||||
[Produces("application/json")]
|
||||
public sealed class VulnerabilitiesController : ControllerBase
|
||||
{
|
||||
private static readonly DateTimeOffset FixedComputedAt = DateTimeOffset.Parse("2026-03-11T00:00:00Z");
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ScannerVulnerabilitiesResponseDto), StatusCodes.Status200OK)]
|
||||
public IActionResult List(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? reachability = null,
|
||||
[FromQuery] bool includeReachability = false)
|
||||
{
|
||||
var filtered = ApplyFilters(
|
||||
DeterministicTriageDemoCatalog.ListVulnerabilities(includeReachability),
|
||||
severity,
|
||||
status,
|
||||
search,
|
||||
reachability);
|
||||
var normalizedPage = Math.Max(page, 1);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||
var total = filtered.Count;
|
||||
var offset = (normalizedPage - 1) * normalizedPageSize;
|
||||
var items = filtered.Skip(offset).Take(normalizedPageSize).ToArray();
|
||||
|
||||
Response.Headers.ETag = "\"scanner-vulnerabilities-v1\"";
|
||||
|
||||
return Ok(new ScannerVulnerabilitiesResponseDto(
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: normalizedPage,
|
||||
PageSize: normalizedPageSize,
|
||||
HasMore: offset + items.Length < total));
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
[ProducesResponseType(typeof(ScannerVulnerabilityStatsDto), StatusCodes.Status200OK)]
|
||||
public IActionResult GetStats()
|
||||
{
|
||||
Response.Headers.ETag = "\"scanner-vulnerability-stats-v1\"";
|
||||
var all = DeterministicTriageDemoCatalog.ListVulnerabilities(includeReachability: true);
|
||||
|
||||
return Ok(new ScannerVulnerabilityStatsDto(
|
||||
Total: all.Count,
|
||||
BySeverity: new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["critical"] = all.Count(item => item.Severity == "critical"),
|
||||
["high"] = all.Count(item => item.Severity == "high"),
|
||||
["medium"] = all.Count(item => item.Severity == "medium"),
|
||||
["low"] = all.Count(item => item.Severity == "low"),
|
||||
["unknown"] = all.Count(item => item.Severity == "unknown"),
|
||||
},
|
||||
ByStatus: new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["open"] = all.Count(item => item.Status == "open"),
|
||||
["fixed"] = all.Count(item => item.Status == "fixed"),
|
||||
["wont_fix"] = all.Count(item => item.Status == "wont_fix"),
|
||||
["in_progress"] = all.Count(item => item.Status == "in_progress"),
|
||||
["excepted"] = all.Count(item => item.Status == "excepted"),
|
||||
},
|
||||
WithExceptions: all.Count(item => item.HasException),
|
||||
CriticalOpen: all.Count(item => item.Severity == "critical" && item.Status == "open"),
|
||||
ComputedAt: FixedComputedAt.ToString("O")));
|
||||
}
|
||||
|
||||
[HttpGet("{vulnId}")]
|
||||
[ProducesResponseType(typeof(ScannerVulnerabilityDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetById([FromRoute] string vulnId)
|
||||
{
|
||||
var vulnerability = DeterministicTriageDemoCatalog.GetVulnerability(vulnId);
|
||||
if (vulnerability is null)
|
||||
{
|
||||
return NotFound(new { error = "Vulnerability not found", vulnId });
|
||||
}
|
||||
|
||||
Response.Headers.ETag = $"\"scanner-vulnerability-{vulnerability.VulnId}\"";
|
||||
return Ok(vulnerability);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ScannerVulnerabilityDto> ApplyFilters(
|
||||
IReadOnlyList<ScannerVulnerabilityDto> vulnerabilities,
|
||||
string? severity,
|
||||
string? status,
|
||||
string? search,
|
||||
string? reachability)
|
||||
{
|
||||
IEnumerable<ScannerVulnerabilityDto> query = vulnerabilities;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(severity) && !string.Equals(severity, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => string.Equals(item.Severity, severity, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(item =>
|
||||
item.CveId.Contains(search, StringComparison.OrdinalIgnoreCase)
|
||||
|| item.Title.Contains(search, StringComparison.OrdinalIgnoreCase)
|
||||
|| (item.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reachability) && !string.Equals(reachability, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => string.Equals(item.ReachabilityStatus, reachability, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return query
|
||||
.OrderBy(item => item.VulnId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ScannerVulnerabilitiesResponseDto(
|
||||
IReadOnlyList<ScannerVulnerabilityDto> Items,
|
||||
int Total,
|
||||
int Page,
|
||||
int PageSize,
|
||||
bool HasMore);
|
||||
|
||||
public sealed record ScannerVulnerabilityStatsDto(
|
||||
int Total,
|
||||
IReadOnlyDictionary<string, int> BySeverity,
|
||||
IReadOnlyDictionary<string, int> ByStatus,
|
||||
int WithExceptions,
|
||||
int CriticalOpen,
|
||||
string ComputedAt);
|
||||
|
||||
public sealed record ScannerVulnerabilityDto(
|
||||
string VulnId,
|
||||
string? FindingId,
|
||||
string CveId,
|
||||
string Title,
|
||||
string? Description,
|
||||
string Severity,
|
||||
double? CvssScore,
|
||||
string? CvssVector,
|
||||
string Status,
|
||||
string? PublishedAt,
|
||||
string? ModifiedAt,
|
||||
IReadOnlyList<ScannerAffectedComponentDto> AffectedComponents,
|
||||
IReadOnlyList<string>? References,
|
||||
bool HasException,
|
||||
string? ExceptionId,
|
||||
double? ReachabilityScore,
|
||||
string? ReachabilityStatus,
|
||||
double? EpssScore,
|
||||
bool? KevListed,
|
||||
int? BlastRadiusAssetCount);
|
||||
|
||||
public sealed record ScannerAffectedComponentDto(
|
||||
string Purl,
|
||||
string Name,
|
||||
string Version,
|
||||
string? FixedVersion,
|
||||
IReadOnlyList<string> AssetIds);
|
||||
@@ -237,6 +237,9 @@ builder.Services.AddDbContext<TriageDbContext>(options =>
|
||||
}));
|
||||
builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
|
||||
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
|
||||
builder.Services.AddScoped<IGatingReasonService, GatingReasonService>();
|
||||
builder.Services.AddScoped<IUnifiedEvidenceService, UnifiedEvidenceService>();
|
||||
builder.Services.AddScoped<IEvidenceBundleExporter, EvidenceBundleExporter>();
|
||||
builder.Services.TryAddScoped<IFindingQueryService, FindingQueryService>();
|
||||
builder.Services.TryAddSingleton<IExploitPathGroupingService, ExploitPathGroupingService>();
|
||||
builder.Services.AddScoped<IUnknownsQueryService, UnknownsQueryService>();
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Controllers;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal static class DeterministicTriageDemoCatalog
|
||||
{
|
||||
internal const string AssetWebProd = "asset-web-prod";
|
||||
|
||||
private static readonly DateTimeOffset FixedGeneratedAt = DateTimeOffset.Parse("2026-03-11T00:00:00Z");
|
||||
private static readonly DemoFinding[] Findings =
|
||||
[
|
||||
new DemoFinding(
|
||||
VulnId: "vuln-001",
|
||||
FindingId: "11111111-1111-1111-1111-111111111111",
|
||||
ScanId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
CveId: "CVE-2021-44228",
|
||||
Title: "Log4Shell - Remote Code Execution in Apache Log4j",
|
||||
Description: "Apache Log4j2 JNDI features allowed attacker-controlled lookup endpoints.",
|
||||
Severity: "critical",
|
||||
CvssScore: 10.0,
|
||||
CvssVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
|
||||
Status: "open",
|
||||
PublishedAt: "2021-12-10T00:00:00Z",
|
||||
ModifiedAt: "2024-06-27T00:00:00Z",
|
||||
Components:
|
||||
[
|
||||
new DemoComponent(
|
||||
Purl: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
|
||||
Name: "log4j-core",
|
||||
Version: "2.14.1",
|
||||
FixedVersion: "2.17.1",
|
||||
AssetIds: [AssetWebProd, "asset-api-prod"])
|
||||
],
|
||||
References:
|
||||
[
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2021-44228",
|
||||
"https://logging.apache.org/log4j/2.x/security.html"
|
||||
],
|
||||
HasException: false,
|
||||
ExceptionId: null,
|
||||
ReachabilityScore: 0.95,
|
||||
ReachabilityStatus: "reachable",
|
||||
EpssScore: 0.97,
|
||||
KevListed: true,
|
||||
BlastRadiusAssetCount: 2,
|
||||
GatingReason: GatingReason.None,
|
||||
GatingExplanation: null,
|
||||
WouldShowIf: null,
|
||||
VexStatus: "affected",
|
||||
PolicyVerdict: "deny",
|
||||
AttestationStatus: "verified",
|
||||
HasTransparencyProof: true),
|
||||
new DemoFinding(
|
||||
VulnId: "vuln-003",
|
||||
FindingId: "33333333-3333-3333-3333-333333333333",
|
||||
ScanId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
CveId: "CVE-2023-44487",
|
||||
Title: "HTTP/2 Rapid Reset Attack",
|
||||
Description: "HTTP/2 request cancellation can reset many streams quickly and consume server resources.",
|
||||
Severity: "high",
|
||||
CvssScore: 7.5,
|
||||
CvssVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
|
||||
Status: "in_progress",
|
||||
PublishedAt: "2023-10-10T00:00:00Z",
|
||||
ModifiedAt: "2024-05-01T00:00:00Z",
|
||||
Components:
|
||||
[
|
||||
new DemoComponent(
|
||||
Purl: "pkg:npm/nghttp2@1.55.0",
|
||||
Name: "nghttp2",
|
||||
Version: "1.55.0",
|
||||
FixedVersion: "1.57.0",
|
||||
AssetIds: [AssetWebProd])
|
||||
],
|
||||
References: [],
|
||||
HasException: false,
|
||||
ExceptionId: null,
|
||||
ReachabilityScore: 0.12,
|
||||
ReachabilityStatus: "unreachable",
|
||||
EpssScore: 0.58,
|
||||
KevListed: false,
|
||||
BlastRadiusAssetCount: 1,
|
||||
GatingReason: GatingReason.Unreachable,
|
||||
GatingExplanation: "Vulnerable code is not reachable from any application entrypoint.",
|
||||
WouldShowIf: ["Add new entrypoint trace", "Enable 'show unreachable' filter"],
|
||||
VexStatus: "not_affected",
|
||||
PolicyVerdict: "warn",
|
||||
AttestationStatus: "verified",
|
||||
HasTransparencyProof: true),
|
||||
new DemoFinding(
|
||||
VulnId: "vuln-005",
|
||||
FindingId: "55555555-5555-5555-5555-555555555555",
|
||||
ScanId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
CveId: "CVE-2023-38545",
|
||||
Title: "curl SOCKS5 heap buffer overflow",
|
||||
Description: "curl can overflow a heap-based buffer in the SOCKS5 proxy handshake.",
|
||||
Severity: "high",
|
||||
CvssScore: 9.8,
|
||||
CvssVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
Status: "open",
|
||||
PublishedAt: "2023-10-11T00:00:00Z",
|
||||
ModifiedAt: "2024-06-10T00:00:00Z",
|
||||
Components:
|
||||
[
|
||||
new DemoComponent(
|
||||
Purl: "pkg:deb/debian/curl@7.88.1-10",
|
||||
Name: "curl",
|
||||
Version: "7.88.1-10",
|
||||
FixedVersion: "8.4.0",
|
||||
AssetIds: [AssetWebProd, "asset-api-prod", "asset-worker-prod"])
|
||||
],
|
||||
References: [],
|
||||
HasException: false,
|
||||
ExceptionId: null,
|
||||
ReachabilityScore: 0.78,
|
||||
ReachabilityStatus: "reachable",
|
||||
EpssScore: 0.81,
|
||||
KevListed: true,
|
||||
BlastRadiusAssetCount: 3,
|
||||
GatingReason: GatingReason.PolicyDismissed,
|
||||
GatingExplanation: "Policy 'runtime-risk-budget-v2' dismissed this finding: low exploitability in the current environment.",
|
||||
WouldShowIf: ["Update policy to remove dismissal rule", "Remove policy exception"],
|
||||
VexStatus: "affected",
|
||||
PolicyVerdict: "waive",
|
||||
AttestationStatus: "verified",
|
||||
HasTransparencyProof: true),
|
||||
new DemoFinding(
|
||||
VulnId: "vuln-007",
|
||||
FindingId: "77777777-7777-7777-7777-777777777777",
|
||||
ScanId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
CveId: "CVE-2023-45853",
|
||||
Title: "MiniZip integer overflow in zipOpenNewFileInZip4_64",
|
||||
Description: "MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow.",
|
||||
Severity: "medium",
|
||||
CvssScore: 5.3,
|
||||
CvssVector: null,
|
||||
Status: "open",
|
||||
PublishedAt: "2023-10-14T00:00:00Z",
|
||||
ModifiedAt: "2024-07-15T00:00:00Z",
|
||||
Components:
|
||||
[
|
||||
new DemoComponent(
|
||||
Purl: "pkg:deb/debian/zlib@1.2.13",
|
||||
Name: "zlib",
|
||||
Version: "1.2.13",
|
||||
FixedVersion: "1.3.1",
|
||||
AssetIds: [AssetWebProd])
|
||||
],
|
||||
References: [],
|
||||
HasException: false,
|
||||
ExceptionId: null,
|
||||
ReachabilityScore: 0.34,
|
||||
ReachabilityStatus: "unknown",
|
||||
EpssScore: 0.27,
|
||||
KevListed: false,
|
||||
BlastRadiusAssetCount: 1,
|
||||
GatingReason: GatingReason.Backported,
|
||||
GatingExplanation: "Vulnerability is fixed via distro backport in version 1.2.13-9+deb12u1.",
|
||||
WouldShowIf: ["Override backport detection", "Report false positive in backport fix"],
|
||||
VexStatus: "not_affected",
|
||||
PolicyVerdict: "pass",
|
||||
AttestationStatus: "verified",
|
||||
HasTransparencyProof: false),
|
||||
new DemoFinding(
|
||||
VulnId: "vuln-002",
|
||||
FindingId: "22222222-2222-2222-2222-222222222222",
|
||||
ScanId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
CveId: "CVE-2021-45046",
|
||||
Title: "Log4j2 Thread Context Message Pattern DoS",
|
||||
Description: "The Log4Shell fix in 2.15.0 was incomplete in certain non-default configurations.",
|
||||
Severity: "critical",
|
||||
CvssScore: 9.0,
|
||||
CvssVector: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H",
|
||||
Status: "excepted",
|
||||
PublishedAt: "2021-12-14T00:00:00Z",
|
||||
ModifiedAt: "2023-11-06T00:00:00Z",
|
||||
Components:
|
||||
[
|
||||
new DemoComponent(
|
||||
Purl: "pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0",
|
||||
Name: "log4j-core",
|
||||
Version: "2.15.0",
|
||||
FixedVersion: "2.17.1",
|
||||
AssetIds: ["asset-internal-001"])
|
||||
],
|
||||
References: [],
|
||||
HasException: true,
|
||||
ExceptionId: "exc-test-001",
|
||||
ReachabilityScore: 0.82,
|
||||
ReachabilityStatus: "unreachable",
|
||||
EpssScore: 0.66,
|
||||
KevListed: false,
|
||||
BlastRadiusAssetCount: 1,
|
||||
GatingReason: GatingReason.VexNotAffected,
|
||||
GatingExplanation: "VEX statement from 'vendor-cert' declares not_affected (trust: 90%).",
|
||||
WouldShowIf: ["Contest the VEX statement", "Lower trust threshold in policy"],
|
||||
VexStatus: "not_affected",
|
||||
PolicyVerdict: "pass",
|
||||
AttestationStatus: "verified",
|
||||
HasTransparencyProof: true),
|
||||
new DemoFinding(
|
||||
VulnId: "vuln-006",
|
||||
FindingId: "66666666-6666-6666-6666-666666666666",
|
||||
ScanId: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
CveId: "CVE-2022-22965",
|
||||
Title: "Spring4Shell - Spring Framework RCE",
|
||||
Description: "A Spring MVC or WebFlux application on JDK 9+ may be vulnerable via data binding.",
|
||||
Severity: "critical",
|
||||
CvssScore: 9.8,
|
||||
CvssVector: null,
|
||||
Status: "wont_fix",
|
||||
PublishedAt: "2022-03-31T00:00:00Z",
|
||||
ModifiedAt: "2024-08-20T00:00:00Z",
|
||||
Components:
|
||||
[
|
||||
new DemoComponent(
|
||||
Purl: "pkg:maven/org.springframework/spring-beans@5.3.17",
|
||||
Name: "spring-beans",
|
||||
Version: "5.3.17",
|
||||
FixedVersion: "5.3.18",
|
||||
AssetIds: ["asset-legacy-001"])
|
||||
],
|
||||
References: [],
|
||||
HasException: true,
|
||||
ExceptionId: "exc-legacy-spring",
|
||||
ReachabilityScore: 0.49,
|
||||
ReachabilityStatus: "unknown",
|
||||
EpssScore: 0.74,
|
||||
KevListed: false,
|
||||
BlastRadiusAssetCount: 1,
|
||||
GatingReason: GatingReason.UserMuted,
|
||||
GatingExplanation: "This finding has been muted by a user decision.",
|
||||
WouldShowIf: ["Un-mute the finding in triage settings"],
|
||||
VexStatus: "under_investigation",
|
||||
PolicyVerdict: "warn",
|
||||
AttestationStatus: "unverified",
|
||||
HasTransparencyProof: false),
|
||||
];
|
||||
|
||||
internal static IReadOnlyList<ScannerVulnerabilityDto> ListVulnerabilities(bool includeReachability)
|
||||
=> Findings.Select(item => item.ToVulnerabilityDto(includeReachability)).ToArray();
|
||||
|
||||
internal static ScannerVulnerabilityDto? GetVulnerability(string vulnId)
|
||||
=> Findings
|
||||
.FirstOrDefault(item => string.Equals(item.VulnId, vulnId, StringComparison.OrdinalIgnoreCase))
|
||||
?.ToVulnerabilityDto(includeReachability: true);
|
||||
|
||||
internal static FindingGatingStatusDto? GetGatingStatus(string findingId)
|
||||
=> Findings
|
||||
.FirstOrDefault(item => string.Equals(item.FindingId, findingId, StringComparison.OrdinalIgnoreCase))
|
||||
?.ToGatingStatus();
|
||||
|
||||
internal static GatedBucketsSummaryDto? GetScanSummary(string scanId)
|
||||
{
|
||||
var matching = Findings
|
||||
.Where(item => string.Equals(item.ScanId, scanId, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
return matching.Length == 0 ? null : BuildSummary(matching);
|
||||
}
|
||||
|
||||
internal static GatedBucketsSummaryDto? GetArtifactSummary(string artifactId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var matching = Findings
|
||||
.Where(item => item.Components.Any(component => component.AssetIds.Contains(artifactId, StringComparer.OrdinalIgnoreCase)))
|
||||
.ToArray();
|
||||
|
||||
return matching.Length == 0 ? GatedBucketsSummaryDto.Empty : BuildSummary(matching);
|
||||
}
|
||||
|
||||
internal static UnifiedEvidenceResponseDto? GetUnifiedEvidence(string findingId)
|
||||
=> Findings
|
||||
.FirstOrDefault(item => string.Equals(item.FindingId, findingId, StringComparison.OrdinalIgnoreCase))
|
||||
?.ToUnifiedEvidence();
|
||||
|
||||
private static GatedBucketsSummaryDto BuildSummary(IEnumerable<DemoFinding> findings)
|
||||
{
|
||||
var statuses = findings.Select(item => item.GatingReason).ToArray();
|
||||
return new GatedBucketsSummaryDto
|
||||
{
|
||||
UnreachableCount = statuses.Count(reason => reason == GatingReason.Unreachable),
|
||||
PolicyDismissedCount = statuses.Count(reason => reason == GatingReason.PolicyDismissed),
|
||||
BackportedCount = statuses.Count(reason => reason == GatingReason.Backported),
|
||||
VexNotAffectedCount = statuses.Count(reason => reason == GatingReason.VexNotAffected),
|
||||
SupersededCount = statuses.Count(reason => reason == GatingReason.Superseded),
|
||||
UserMutedCount = statuses.Count(reason => reason == GatingReason.UserMuted),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record DemoFinding(
|
||||
string VulnId,
|
||||
string FindingId,
|
||||
string ScanId,
|
||||
string CveId,
|
||||
string Title,
|
||||
string Description,
|
||||
string Severity,
|
||||
double? CvssScore,
|
||||
string? CvssVector,
|
||||
string Status,
|
||||
string PublishedAt,
|
||||
string ModifiedAt,
|
||||
IReadOnlyList<DemoComponent> Components,
|
||||
IReadOnlyList<string> References,
|
||||
bool HasException,
|
||||
string? ExceptionId,
|
||||
double ReachabilityScore,
|
||||
string ReachabilityStatus,
|
||||
double EpssScore,
|
||||
bool KevListed,
|
||||
int BlastRadiusAssetCount,
|
||||
GatingReason GatingReason,
|
||||
string? GatingExplanation,
|
||||
IReadOnlyList<string>? WouldShowIf,
|
||||
string VexStatus,
|
||||
string PolicyVerdict,
|
||||
string AttestationStatus,
|
||||
bool HasTransparencyProof)
|
||||
{
|
||||
public ScannerVulnerabilityDto ToVulnerabilityDto(bool includeReachability) =>
|
||||
new(
|
||||
VulnId: VulnId,
|
||||
FindingId: FindingId,
|
||||
CveId: CveId,
|
||||
Title: Title,
|
||||
Description: Description,
|
||||
Severity: Severity,
|
||||
CvssScore: CvssScore,
|
||||
CvssVector: CvssVector,
|
||||
Status: Status,
|
||||
PublishedAt: PublishedAt,
|
||||
ModifiedAt: ModifiedAt,
|
||||
AffectedComponents: Components
|
||||
.Select(component => new ScannerAffectedComponentDto(
|
||||
component.Purl,
|
||||
component.Name,
|
||||
component.Version,
|
||||
component.FixedVersion,
|
||||
component.AssetIds))
|
||||
.ToArray(),
|
||||
References: References,
|
||||
HasException: HasException,
|
||||
ExceptionId: ExceptionId,
|
||||
ReachabilityScore: includeReachability ? ReachabilityScore : null,
|
||||
ReachabilityStatus: includeReachability ? ReachabilityStatus : null,
|
||||
EpssScore: EpssScore,
|
||||
KevListed: KevListed,
|
||||
BlastRadiusAssetCount: BlastRadiusAssetCount);
|
||||
|
||||
public FindingGatingStatusDto ToGatingStatus() =>
|
||||
new()
|
||||
{
|
||||
GatingReason = GatingReason,
|
||||
IsHiddenByDefault = GatingReason != GatingReason.None,
|
||||
SubgraphId = $"subgraph-{FindingId}",
|
||||
DeltasId = $"delta-{FindingId}",
|
||||
GatingExplanation = GatingExplanation,
|
||||
WouldShowIf = WouldShowIf,
|
||||
};
|
||||
|
||||
public UnifiedEvidenceResponseDto ToUnifiedEvidence()
|
||||
{
|
||||
var component = Components[0];
|
||||
return new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = FindingId,
|
||||
CveId = CveId,
|
||||
ComponentPurl = component.Purl,
|
||||
Sbom = new SbomEvidenceDto
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
Version = "1.5",
|
||||
DocumentUri = $"/security/sbom-lake?artifact={Uri.EscapeDataString(component.Purl)}",
|
||||
Digest = $"sha256:sbom-{FindingId}",
|
||||
Component = new SbomComponentDto
|
||||
{
|
||||
Purl = component.Purl,
|
||||
Name = component.Name,
|
||||
Version = component.Version,
|
||||
Ecosystem = "demo",
|
||||
},
|
||||
Dependencies = ["pkg:demo/dependency@1.0.0"],
|
||||
Dependents = ["pkg:oci/stella-ops/asset@sha256:demo"],
|
||||
},
|
||||
Reachability = new ReachabilityEvidenceDto
|
||||
{
|
||||
SubgraphId = $"subgraph-{FindingId}",
|
||||
Status = ReachabilityStatus,
|
||||
Confidence = ReachabilityScore,
|
||||
Method = "static",
|
||||
EntryPoints =
|
||||
[
|
||||
new EntryPointDto
|
||||
{
|
||||
Id = $"entry-{FindingId}",
|
||||
Type = "http",
|
||||
Name = "/api/orders",
|
||||
Location = "src/api/orders.ts:42",
|
||||
Distance = 3,
|
||||
}
|
||||
],
|
||||
CallChain = new CallChainSummaryDto
|
||||
{
|
||||
PathLength = ReachabilityStatus == "reachable" ? 4 : 1,
|
||||
PathCount = ReachabilityStatus == "reachable" ? 2 : 0,
|
||||
KeySymbols = ReachabilityStatus == "reachable"
|
||||
? ["OrderController.handle", "LoggerFacade.warn", "JndiLookup.lookup"]
|
||||
: [],
|
||||
CallGraphUri = $"/security/reachability?findingId={Uri.EscapeDataString(FindingId)}",
|
||||
},
|
||||
GraphUri = $"/security/reachability?findingId={Uri.EscapeDataString(FindingId)}",
|
||||
},
|
||||
VexClaims =
|
||||
[
|
||||
new VexClaimDto
|
||||
{
|
||||
StatementId = $"vex-{FindingId}",
|
||||
Source = "demo-vex",
|
||||
Status = VexStatus,
|
||||
Justification = VexStatus == "not_affected" ? "component_not_present" : "requires_context",
|
||||
ImpactStatement = "Deterministic local setup sample evidence.",
|
||||
IssuedAt = FixedGeneratedAt,
|
||||
TrustScore = VexStatus == "not_affected" ? 0.9 : 0.65,
|
||||
MeetsPolicyThreshold = VexStatus == "not_affected",
|
||||
DocumentUri = $"/security/advisories-vex?findingId={Uri.EscapeDataString(FindingId)}",
|
||||
}
|
||||
],
|
||||
Attestations =
|
||||
[
|
||||
new AttestationSummaryDto
|
||||
{
|
||||
Id = $"att-{FindingId}",
|
||||
PredicateType = "https://stellaops.dev/demo/triage-evidence",
|
||||
SubjectDigest = $"sha256:artifact-{FindingId}",
|
||||
Signer = "stella-demo-signer",
|
||||
SignedAt = FixedGeneratedAt,
|
||||
VerificationStatus = AttestationStatus,
|
||||
TransparencyLogEntry = HasTransparencyProof ? $"rekor://demo/{FindingId}" : null,
|
||||
AttestationUri = $"/evidence/attestations/{Uri.EscapeDataString(FindingId)}",
|
||||
}
|
||||
],
|
||||
Deltas = new DeltaEvidenceDto
|
||||
{
|
||||
DeltaId = $"delta-{FindingId}",
|
||||
PreviousScanId = "previous-demo-scan",
|
||||
CurrentScanId = ScanId,
|
||||
ComparedAt = FixedGeneratedAt,
|
||||
Summary = new DeltaSummaryDto
|
||||
{
|
||||
AddedCount = 1,
|
||||
RemovedCount = 0,
|
||||
ChangedCount = 1,
|
||||
IsNew = Status == "open",
|
||||
StatusChanged = Status == "in_progress",
|
||||
PreviousStatus = Status == "in_progress" ? "open" : null,
|
||||
},
|
||||
DeltaReportUri = $"/security/findings?findingId={Uri.EscapeDataString(FindingId)}",
|
||||
},
|
||||
Policy = new PolicyEvidenceDto
|
||||
{
|
||||
PolicyVersion = "demo-policy-v2",
|
||||
PolicyDigest = $"sha256:policy-{FindingId}",
|
||||
Verdict = PolicyVerdict,
|
||||
RulesFired =
|
||||
[
|
||||
new PolicyRuleFiredDto
|
||||
{
|
||||
RuleId = "runtime-risk-budget-v2",
|
||||
Name = "Runtime risk budget",
|
||||
Effect = PolicyVerdict,
|
||||
Reason = GatingExplanation ?? "Finding remains actionable in the current artifact context.",
|
||||
}
|
||||
],
|
||||
PolicyDocumentUri = "/ops/policy/risk-budget",
|
||||
},
|
||||
Manifests = new ManifestHashesDto
|
||||
{
|
||||
ArtifactDigest = $"sha256:artifact-{FindingId}",
|
||||
ManifestHash = $"sha256:manifest-{FindingId}",
|
||||
FeedSnapshotHash = $"sha256:feed-{FindingId}",
|
||||
PolicyHash = $"sha256:policy-{FindingId}",
|
||||
KnowledgeSnapshotId = $"snapshot-{FindingId}",
|
||||
},
|
||||
Verification = new VerificationStatusDto
|
||||
{
|
||||
Status = AttestationStatus == "verified" ? "verified" : "partial",
|
||||
HashesVerified = true,
|
||||
AttestationsVerified = AttestationStatus == "verified",
|
||||
EvidenceComplete = true,
|
||||
Issues = AttestationStatus == "verified" ? null : ["No verified attestation is available for this demo finding."],
|
||||
VerifiedAt = FixedGeneratedAt,
|
||||
},
|
||||
ReplayCommand = $"stella triage replay --finding-id {FindingId}",
|
||||
ShortReplayCommand = $"stella triage replay --snapshot snapshot-{FindingId}",
|
||||
EvidenceBundleUrl = $"/api/v1/triage/findings/{Uri.EscapeDataString(FindingId)}/evidence/export?format=zip",
|
||||
GeneratedAt = FixedGeneratedAt,
|
||||
CacheKey = $"demo-{FindingId}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record DemoComponent(
|
||||
string Purl,
|
||||
string Name,
|
||||
string Version,
|
||||
string? FixedVersion,
|
||||
IReadOnlyList<string> AssetIds);
|
||||
}
|
||||
@@ -41,6 +41,12 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var demoStatus = DeterministicTriageDemoCatalog.GetGatingStatus(findingId);
|
||||
if (demoStatus is not null)
|
||||
{
|
||||
return demoStatus;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(findingId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding id format: {FindingId}", findingId);
|
||||
@@ -76,14 +82,21 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var demoStatuses = findingIds
|
||||
.Select(id => DeterministicTriageDemoCatalog.GetGatingStatus(id))
|
||||
.Where(status => status is not null)
|
||||
.Select(status => status!)
|
||||
.ToList();
|
||||
|
||||
var validIds = findingIds
|
||||
.Where(id => DeterministicTriageDemoCatalog.GetGatingStatus(id) is null)
|
||||
.Where(id => Guid.TryParse(id, out _))
|
||||
.Select(Guid.Parse)
|
||||
.ToList();
|
||||
|
||||
if (validIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<FindingGatingStatusDto>();
|
||||
return demoStatuses;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
@@ -97,8 +110,8 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return findings
|
||||
.Select(ComputeGatingStatus)
|
||||
return demoStatuses
|
||||
.Concat(findings.Select(ComputeGatingStatus))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -110,6 +123,12 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var demoSummary = DeterministicTriageDemoCatalog.GetScanSummary(scanId);
|
||||
if (demoSummary is not null)
|
||||
{
|
||||
return demoSummary;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(scanId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid scan id format: {ScanId}", scanId);
|
||||
@@ -146,6 +165,49 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GatedBucketsSummaryDto> GetArtifactGatedBucketsSummaryAsync(
|
||||
string tenantId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var demoSummary = DeterministicTriageDemoCatalog.GetArtifactSummary(artifactId);
|
||||
if (demoSummary is not null)
|
||||
{
|
||||
return demoSummary;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var findings = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.PolicyDecisions)
|
||||
.AsNoTracking()
|
||||
.Where(f => f.TenantId == normalizedTenantId && f.AssetLabel == artifactId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (findings.Count == 0)
|
||||
{
|
||||
return GatedBucketsSummaryDto.Empty;
|
||||
}
|
||||
|
||||
var gatingStatuses = findings.Select(ComputeGatingStatus).ToList();
|
||||
|
||||
return new GatedBucketsSummaryDto
|
||||
{
|
||||
UnreachableCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.Unreachable),
|
||||
PolicyDismissedCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.PolicyDismissed),
|
||||
BackportedCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.Backported),
|
||||
VexNotAffectedCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.VexNotAffected),
|
||||
SupersededCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.Superseded),
|
||||
UserMutedCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.UserMuted)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the gating status for a finding based on its evidence.
|
||||
/// </summary>
|
||||
|
||||
@@ -48,4 +48,16 @@ public interface IGatingReasonService
|
||||
string tenantId,
|
||||
string scanId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the gated buckets summary for an artifact workspace.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="artifactId">Artifact identifier from the workspace route.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Summary of gated buckets for the artifact.</returns>
|
||||
Task<GatedBucketsSummaryDto> GetArtifactGatedBucketsSummaryAsync(
|
||||
string tenantId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
options ??= new UnifiedEvidenceOptions();
|
||||
|
||||
var demoEvidence = DeterministicTriageDemoCatalog.GetUnifiedEvidence(findingId);
|
||||
if (demoEvidence is not null)
|
||||
{
|
||||
return demoEvidence;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(findingId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding id format: {FindingId}", findingId);
|
||||
|
||||
@@ -21,3 +21,4 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
||||
| SPRINT-20260222-057-SCAN-TEN-11 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: propagated resolved tenant context through SmartDiff/Reachability endpoints into tenant-partitioned repository queries (2026-02-23). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: updated source-run and secret-exception service/endpoints to require tenant-scoped repository lookups for API-backed tenant tables (2026-02-23). |
|
||||
| SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Scanner WebService and replaced selected hardcoded endpoint strings with `_t(...)` keys (en-US/de-DE bundles added). |
|
||||
| SPRINT-20260311-003-VULNREAD-001 | DONE | `SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md`: restored the documented scanner-backed `/api/v1/vulnerabilities` read contract for the live triage artifact workspace, with targeted controller tests and compose redeploy proof (2026-03-11). |
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class TriageControllerDemoContractsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ArtifactGatedBuckets_ReturnsDeterministicSummaryForArtifactWorkspace()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight();
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/triage/artifacts/asset-web-prod/gated-buckets",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<GatedBucketsSummaryDto>(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(payload);
|
||||
Assert.True(payload!.TotalHiddenCount >= 1);
|
||||
Assert.True(payload.UnreachableCount >= 1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DemoFindingEvidence_ReturnsUnifiedEvidenceWithoutDatabaseBacking()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight();
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
const string findingId = "11111111-1111-1111-1111-111111111111";
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v1/triage/findings/{findingId}/evidence?includeReplayCommand=true&includeReachability=true&includeVex=true&includeAttestations=true&includeDeltas=true",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<UnifiedEvidenceResponseDto>(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(findingId, payload!.FindingId);
|
||||
Assert.NotNull(payload.Reachability);
|
||||
Assert.NotEmpty(payload.Attestations ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.Scanner.WebService.Controllers;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class VulnerabilitiesControllerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsDeterministicReachabilityAwarePayload()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight();
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var firstResponse = await client.GetAsync("/api/v1/vulnerabilities?includeReachability=true", TestContext.Current.CancellationToken);
|
||||
var secondResponse = await client.GetAsync("/api/v1/vulnerabilities?includeReachability=true", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
|
||||
var first = await firstResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
var second = await secondResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(first, second);
|
||||
|
||||
var payload = await firstResponse.Content.ReadFromJsonAsync<ScannerVulnerabilitiesResponseDto>(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotEmpty(payload!.Items);
|
||||
Assert.All(payload.Items, item => Assert.False(string.IsNullOrWhiteSpace(item.VulnId)));
|
||||
Assert.Contains(payload.Items, item => item.ReachabilityStatus is not null);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_AppliesSeverityAndReachabilityFilters()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight();
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var payload = await client.GetFromJsonAsync<ScannerVulnerabilitiesResponseDto>(
|
||||
"/api/v1/vulnerabilities?severity=critical&reachability=reachable&includeReachability=true",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotEmpty(payload!.Items);
|
||||
Assert.All(payload.Items, item =>
|
||||
{
|
||||
Assert.Equal("critical", item.Severity);
|
||||
Assert.Equal("reachable", item.ReachabilityStatus);
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DetailAndStats_ReturnExpectedContracts()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight();
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var detail = await client.GetFromJsonAsync<ScannerVulnerabilityDto>(
|
||||
"/api/v1/vulnerabilities/vuln-001",
|
||||
TestContext.Current.CancellationToken);
|
||||
var stats = await client.GetFromJsonAsync<ScannerVulnerabilityStatsDto>(
|
||||
"/api/v1/vulnerabilities/status",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(detail);
|
||||
Assert.Equal("CVE-2021-44228", detail!.CveId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(detail.FindingId));
|
||||
Assert.NotEmpty(detail.AffectedComponents);
|
||||
|
||||
Assert.NotNull(stats);
|
||||
Assert.True(stats!.Total >= 1);
|
||||
Assert.Equal(stats.BySeverity.Values.Sum(), stats.Total);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user