partly or unimplemented features - now implemented
This commit is contained in:
@@ -262,3 +262,129 @@ public sealed record VexGateResultsQuery
|
||||
/// </summary>
|
||||
public int? Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for VEX + reachability decision filtering.
|
||||
/// </summary>
|
||||
public sealed record VexReachabilityFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Findings to evaluate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findings")]
|
||||
public required IReadOnlyList<VexReachabilityFilterFindingDto> Findings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input finding for VEX + reachability filtering.
|
||||
/// </summary>
|
||||
public sealed record VexReachabilityFilterFindingDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingId")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vendor VEX status (e.g. not_affected, affected, fixed, under_investigation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vendorStatus")]
|
||||
public string? VendorStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability tier (confirmed, likely, present, unreachable, unknown).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachabilityTier")]
|
||||
public required string ReachabilityTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Existing gate decision (pass, warn, block).
|
||||
/// </summary>
|
||||
[JsonPropertyName("existingDecision")]
|
||||
public string? ExistingDecision { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for VEX + reachability decision filtering.
|
||||
/// </summary>
|
||||
public sealed record VexReachabilityFilterResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Annotated findings after matrix evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findings")]
|
||||
public required IReadOnlyList<VexReachabilityFilterDecisionDto> Findings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate summary for filter actions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required VexReachabilityFilterSummaryDto Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matrix evaluation result for a single finding.
|
||||
/// </summary>
|
||||
public sealed record VexReachabilityFilterDecisionDto
|
||||
{
|
||||
[JsonPropertyName("findingId")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("vendorStatus")]
|
||||
public string? VendorStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("reachabilityTier")]
|
||||
public required string ReachabilityTier { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
[JsonPropertyName("effectiveDecision")]
|
||||
public required string EffectiveDecision { get; init; }
|
||||
|
||||
[JsonPropertyName("matrixRule")]
|
||||
public required string MatrixRule { get; init; }
|
||||
|
||||
[JsonPropertyName("rationale")]
|
||||
public required string Rationale { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts for VEX + reachability filter actions.
|
||||
/// </summary>
|
||||
public sealed record VexReachabilityFilterSummaryDto
|
||||
{
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("suppressed")]
|
||||
public int Suppressed { get; init; }
|
||||
|
||||
[JsonPropertyName("elevated")]
|
||||
public int Elevated { get; init; }
|
||||
|
||||
[JsonPropertyName("passThrough")]
|
||||
public int PassThrough { get; init; }
|
||||
|
||||
[JsonPropertyName("flagForReview")]
|
||||
public int FlagForReview { get; init; }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
@@ -20,13 +21,16 @@ namespace StellaOps.Scanner.WebService.Controllers;
|
||||
public sealed class VexGateController : ControllerBase
|
||||
{
|
||||
private readonly IVexGateQueryService _gateQueryService;
|
||||
private readonly IVexReachabilityDecisionFilter _vexReachabilityDecisionFilter;
|
||||
private readonly ILogger<VexGateController> _logger;
|
||||
|
||||
public VexGateController(
|
||||
IVexGateQueryService gateQueryService,
|
||||
IVexReachabilityDecisionFilter vexReachabilityDecisionFilter,
|
||||
ILogger<VexGateController> logger)
|
||||
{
|
||||
_gateQueryService = gateQueryService ?? throw new ArgumentNullException(nameof(gateQueryService));
|
||||
_vexReachabilityDecisionFilter = vexReachabilityDecisionFilter ?? throw new ArgumentNullException(nameof(vexReachabilityDecisionFilter));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -140,4 +144,197 @@ public sealed class VexGateController : ControllerBase
|
||||
|
||||
return Ok(results.GatedFindings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate a list of findings with VEX + reachability matrix filtering.
|
||||
/// </summary>
|
||||
/// <param name="request">Findings to evaluate.</param>
|
||||
/// <response code="200">Findings evaluated successfully.</response>
|
||||
/// <response code="400">Request payload is invalid.</response>
|
||||
[HttpPost("vex-reachability/filter")]
|
||||
[ProducesResponseType(typeof(VexReachabilityFilterResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult FilterByVexReachability([FromBody] VexReachabilityFilterRequest request)
|
||||
{
|
||||
if (request?.Findings is null || request.Findings.Count == 0)
|
||||
{
|
||||
return BadRequest(new { error = "At least one finding is required." });
|
||||
}
|
||||
|
||||
var validationErrors = new List<string>();
|
||||
var inputs = new List<VexReachabilityDecisionInput>(request.Findings.Count);
|
||||
|
||||
foreach (var finding in request.Findings)
|
||||
{
|
||||
if (!TryParseReachabilityTier(finding.ReachabilityTier, out var tier))
|
||||
{
|
||||
validationErrors.Add(
|
||||
$"Finding '{finding.FindingId}' has unsupported reachabilityTier '{finding.ReachabilityTier}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseVendorStatus(finding.VendorStatus, out var vendorStatus))
|
||||
{
|
||||
validationErrors.Add(
|
||||
$"Finding '{finding.FindingId}' has unsupported vendorStatus '{finding.VendorStatus}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseDecision(finding.ExistingDecision, out var existingDecision))
|
||||
{
|
||||
validationErrors.Add(
|
||||
$"Finding '{finding.FindingId}' has unsupported existingDecision '{finding.ExistingDecision}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
inputs.Add(new VexReachabilityDecisionInput
|
||||
{
|
||||
FindingId = finding.FindingId,
|
||||
VulnerabilityId = finding.Cve,
|
||||
Purl = finding.Purl,
|
||||
VendorStatus = vendorStatus,
|
||||
ReachabilityTier = tier,
|
||||
ExistingDecision = existingDecision
|
||||
});
|
||||
}
|
||||
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = "One or more findings contain invalid values.",
|
||||
details = validationErrors
|
||||
});
|
||||
}
|
||||
|
||||
var results = _vexReachabilityDecisionFilter.EvaluateBatch(inputs);
|
||||
|
||||
var responseFindings = results
|
||||
.Select(result => new VexReachabilityFilterDecisionDto
|
||||
{
|
||||
FindingId = result.FindingId,
|
||||
Cve = result.VulnerabilityId,
|
||||
Purl = result.Purl,
|
||||
VendorStatus = ToVendorStatusString(result.VendorStatus),
|
||||
ReachabilityTier = ToReachabilityTierString(result.ReachabilityTier),
|
||||
Action = ToActionString(result.Action),
|
||||
EffectiveDecision = ToDecisionString(result.EffectiveDecision),
|
||||
MatrixRule = result.MatrixRule,
|
||||
Rationale = result.Rationale
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var summary = new VexReachabilityFilterSummaryDto
|
||||
{
|
||||
Total = responseFindings.Count,
|
||||
Suppressed = responseFindings.Count(f => string.Equals(f.Action, "suppress", StringComparison.Ordinal)),
|
||||
Elevated = responseFindings.Count(f => string.Equals(f.Action, "elevate", StringComparison.Ordinal)),
|
||||
PassThrough = responseFindings.Count(f => string.Equals(f.Action, "pass_through", StringComparison.Ordinal)),
|
||||
FlagForReview = responseFindings.Count(f => string.Equals(f.Action, "flag_for_review", StringComparison.Ordinal))
|
||||
};
|
||||
|
||||
return Ok(new VexReachabilityFilterResponse
|
||||
{
|
||||
Findings = responseFindings,
|
||||
Summary = summary
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryParseVendorStatus(string? value, out VexStatus? status)
|
||||
{
|
||||
status = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant().Replace('-', '_');
|
||||
status = normalized switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => (VexStatus?)null
|
||||
};
|
||||
|
||||
return status is not null;
|
||||
}
|
||||
|
||||
private static bool TryParseReachabilityTier(string? value, out VexReachabilityTier tier)
|
||||
{
|
||||
tier = VexReachabilityTier.Unknown;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant().Replace('-', '_');
|
||||
tier = normalized switch
|
||||
{
|
||||
"confirmed" => VexReachabilityTier.Confirmed,
|
||||
"likely" => VexReachabilityTier.Likely,
|
||||
"present" => VexReachabilityTier.Present,
|
||||
"unreachable" => VexReachabilityTier.Unreachable,
|
||||
"unknown" => VexReachabilityTier.Unknown,
|
||||
_ => VexReachabilityTier.Unknown
|
||||
};
|
||||
|
||||
return normalized is "confirmed" or "likely" or "present" or "unreachable" or "unknown";
|
||||
}
|
||||
|
||||
private static bool TryParseDecision(string? value, out VexGateDecision decision)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
decision = VexGateDecision.Warn;
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
decision = normalized switch
|
||||
{
|
||||
"pass" => VexGateDecision.Pass,
|
||||
"warn" => VexGateDecision.Warn,
|
||||
"block" => VexGateDecision.Block,
|
||||
_ => VexGateDecision.Warn
|
||||
};
|
||||
|
||||
return normalized is "pass" or "warn" or "block";
|
||||
}
|
||||
|
||||
private static string ToVendorStatusString(VexStatus? status) => status switch
|
||||
{
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static string ToReachabilityTierString(VexReachabilityTier tier) => tier switch
|
||||
{
|
||||
VexReachabilityTier.Confirmed => "confirmed",
|
||||
VexReachabilityTier.Likely => "likely",
|
||||
VexReachabilityTier.Present => "present",
|
||||
VexReachabilityTier.Unreachable => "unreachable",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static string ToActionString(VexReachabilityFilterAction action) => action switch
|
||||
{
|
||||
VexReachabilityFilterAction.Suppress => "suppress",
|
||||
VexReachabilityFilterAction.Elevate => "elevate",
|
||||
VexReachabilityFilterAction.PassThrough => "pass_through",
|
||||
VexReachabilityFilterAction.FlagForReview => "flag_for_review",
|
||||
_ => "pass_through"
|
||||
};
|
||||
|
||||
private static string ToDecisionString(VexGateDecision decision) => decision switch
|
||||
{
|
||||
VexGateDecision.Pass => "pass",
|
||||
VexGateDecision.Warn => "warn",
|
||||
VexGateDecision.Block => "block",
|
||||
_ => "warn"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for exploit-path cluster statistics and batch triage actions.
|
||||
/// </summary>
|
||||
internal static class BatchTriageEndpoints
|
||||
{
|
||||
public static void MapBatchTriageEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var triageGroup = apiGroup.MapGroup("/triage")
|
||||
.WithTags("Triage");
|
||||
|
||||
triageGroup.MapGet("/inbox/clusters/stats", HandleGetClusterStatsAsync)
|
||||
.WithName("scanner.triage.inbox.cluster-stats")
|
||||
.WithDescription("Returns per-cluster severity and reachability distributions.")
|
||||
.Produces<TriageClusterStatsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.TriageRead);
|
||||
|
||||
triageGroup.MapPost("/inbox/clusters/{pathId}/actions", HandleApplyBatchActionAsync)
|
||||
.WithName("scanner.triage.inbox.cluster-action")
|
||||
.WithDescription("Applies one triage action to all findings in an exploit-path cluster.")
|
||||
.Produces<BatchTriageClusterActionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.TriageWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetClusterStatsAsync(
|
||||
[FromQuery] string artifactDigest,
|
||||
[FromQuery] string? filter,
|
||||
[FromQuery] decimal? similarityThreshold,
|
||||
[FromServices] IFindingQueryService findingService,
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid artifact digest",
|
||||
detail = "Artifact digest is required."
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var clusters = similarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken).ConfigureAwait(false)
|
||||
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken).ConfigureAwait(false);
|
||||
var filtered = ApplyFilter(clusters, filter);
|
||||
|
||||
var response = new TriageClusterStatsResponse
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
Filter = filter,
|
||||
TotalClusters = filtered.Count,
|
||||
TotalFindings = filtered.Sum(static c => c.FindingIds.Length),
|
||||
Clusters = filtered.Select(ToClusterStats).OrderByDescending(static c => c.PriorityScore).ThenBy(static c => c.PathId, StringComparer.Ordinal).ToArray(),
|
||||
SeverityDistribution = BuildSeverityDistribution(filtered),
|
||||
ReachabilityDistribution = BuildReachabilityDistribution(filtered),
|
||||
GeneratedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleApplyBatchActionAsync(
|
||||
[FromRoute] string pathId,
|
||||
[FromBody] BatchTriageClusterActionRequest request,
|
||||
[FromServices] IFindingQueryService findingService,
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] ITriageStatusService triageStatusService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid artifact digest",
|
||||
detail = "Artifact digest is required."
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pathId))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid path id",
|
||||
detail = "Path id is required."
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(request.ArtifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var clusters = request.SimilarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, request.SimilarityThreshold.Value, cancellationToken).ConfigureAwait(false)
|
||||
: await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, cancellationToken).ConfigureAwait(false);
|
||||
var cluster = clusters.FirstOrDefault(c => string.Equals(c.PathId, pathId, StringComparison.Ordinal));
|
||||
if (cluster is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Cluster not found",
|
||||
detail = $"Cluster '{pathId}' was not found for artifact '{request.ArtifactDigest}'."
|
||||
});
|
||||
}
|
||||
|
||||
var decisionKind = NormalizeDecisionKind(request.DecisionKind);
|
||||
var lane = string.IsNullOrWhiteSpace(request.Lane) ? ResolveLane(decisionKind) : request.Lane!.Trim();
|
||||
var actor = string.IsNullOrWhiteSpace(request.Actor) ? "batch-triage" : request.Actor!.Trim();
|
||||
|
||||
var updated = ImmutableArray.CreateBuilder<string>(cluster.FindingIds.Length);
|
||||
foreach (var findingId in cluster.FindingIds)
|
||||
{
|
||||
var updateRequest = new UpdateTriageStatusRequestDto
|
||||
{
|
||||
DecisionKind = decisionKind,
|
||||
Lane = lane,
|
||||
Reason = request.Reason,
|
||||
Actor = actor
|
||||
};
|
||||
|
||||
var result = await triageStatusService.UpdateStatusAsync(findingId, updateRequest, actor, cancellationToken).ConfigureAwait(false);
|
||||
if (result is not null)
|
||||
{
|
||||
updated.Add(findingId);
|
||||
}
|
||||
}
|
||||
|
||||
var record = BuildActionRecord(request.ArtifactDigest, pathId, lane, decisionKind, request.Reason, updated.ToImmutable());
|
||||
var response = new BatchTriageClusterActionResponse
|
||||
{
|
||||
PathId = pathId,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
DecisionKind = decisionKind,
|
||||
Lane = lane,
|
||||
RequestedFindingCount = cluster.FindingIds.Length,
|
||||
UpdatedFindingCount = updated.Count,
|
||||
UpdatedFindingIds = updated.ToImmutable(),
|
||||
ActionRecord = record,
|
||||
AppliedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExploitPath> ApplyFilter(IReadOnlyList<ExploitPath> paths, string? filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
return paths;
|
||||
}
|
||||
|
||||
return filter.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"actionable" => paths.Where(static p => !p.IsQuiet && p.Reachability is ReachabilityStatus.StaticallyReachable or ReachabilityStatus.RuntimeConfirmed).ToArray(),
|
||||
"noisy" => paths.Where(static p => p.IsQuiet).ToArray(),
|
||||
"reachable" => paths.Where(static p => p.Reachability is ReachabilityStatus.StaticallyReachable or ReachabilityStatus.RuntimeConfirmed).ToArray(),
|
||||
"runtime" => paths.Where(static p => p.Reachability == ReachabilityStatus.RuntimeConfirmed).ToArray(),
|
||||
"critical" => paths.Where(static p => p.RiskScore.CriticalCount > 0).ToArray(),
|
||||
"high" => paths.Where(static p => p.RiskScore.HighCount > 0).ToArray(),
|
||||
_ => paths
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeDecisionKind(string? decisionKind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(decisionKind))
|
||||
{
|
||||
return "Ack";
|
||||
}
|
||||
|
||||
return decisionKind.Trim() switch
|
||||
{
|
||||
var value when value.Equals("MuteReach", StringComparison.OrdinalIgnoreCase) => "MuteReach",
|
||||
var value when value.Equals("MuteVex", StringComparison.OrdinalIgnoreCase) => "MuteVex",
|
||||
var value when value.Equals("Exception", StringComparison.OrdinalIgnoreCase) => "Exception",
|
||||
_ => "Ack"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveLane(string decisionKind)
|
||||
=> decisionKind switch
|
||||
{
|
||||
"MuteReach" => "MutedReach",
|
||||
"MuteVex" => "MutedVex",
|
||||
"Exception" => "NeedsException",
|
||||
_ => "Active"
|
||||
};
|
||||
|
||||
private static BatchTriageActionRecord BuildActionRecord(
|
||||
string artifactDigest,
|
||||
string pathId,
|
||||
string lane,
|
||||
string decisionKind,
|
||||
string? reason,
|
||||
ImmutableArray<string> findingIds)
|
||||
{
|
||||
var payload = string.Join(
|
||||
"\n",
|
||||
artifactDigest.Trim(),
|
||||
pathId.Trim(),
|
||||
lane.Trim(),
|
||||
decisionKind.Trim(),
|
||||
reason?.Trim() ?? string.Empty,
|
||||
string.Join(",", findingIds.Order(StringComparer.Ordinal)));
|
||||
var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
|
||||
|
||||
return new BatchTriageActionRecord
|
||||
{
|
||||
ActionRecordId = $"triage-action:{digest[..16]}",
|
||||
PayloadDigest = $"sha256:{digest}",
|
||||
Signed = false
|
||||
};
|
||||
}
|
||||
|
||||
private static TriageClusterStatsItem ToClusterStats(ExploitPath path)
|
||||
=> new()
|
||||
{
|
||||
PathId = path.PathId,
|
||||
FindingCount = path.FindingIds.Length,
|
||||
PriorityScore = path.PriorityScore,
|
||||
Reachability = path.Reachability.ToString(),
|
||||
Critical = path.RiskScore.CriticalCount,
|
||||
High = path.RiskScore.HighCount,
|
||||
Medium = path.RiskScore.MediumCount,
|
||||
Low = path.RiskScore.LowCount
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, int> BuildSeverityDistribution(IEnumerable<ExploitPath> paths)
|
||||
{
|
||||
var totals = new SortedDictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["critical"] = 0,
|
||||
["high"] = 0,
|
||||
["medium"] = 0,
|
||||
["low"] = 0
|
||||
};
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
totals["critical"] += path.RiskScore.CriticalCount;
|
||||
totals["high"] += path.RiskScore.HighCount;
|
||||
totals["medium"] += path.RiskScore.MediumCount;
|
||||
totals["low"] += path.RiskScore.LowCount;
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, int> BuildReachabilityDistribution(IEnumerable<ExploitPath> paths)
|
||||
{
|
||||
var totals = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var key = path.Reachability.ToString();
|
||||
totals[key] = totals.TryGetValue(key, out var count) ? count + 1 : 1;
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BatchTriageClusterActionRequest
|
||||
{
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public string? DecisionKind { get; init; }
|
||||
public string? Lane { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public decimal? SimilarityThreshold { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BatchTriageClusterActionResponse
|
||||
{
|
||||
public required string PathId { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string DecisionKind { get; init; }
|
||||
public required string Lane { get; init; }
|
||||
public required int RequestedFindingCount { get; init; }
|
||||
public required int UpdatedFindingCount { get; init; }
|
||||
public required IReadOnlyList<string> UpdatedFindingIds { get; init; }
|
||||
public required BatchTriageActionRecord ActionRecord { get; init; }
|
||||
public required DateTimeOffset AppliedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BatchTriageActionRecord
|
||||
{
|
||||
public required string ActionRecordId { get; init; }
|
||||
public required string PayloadDigest { get; init; }
|
||||
public required bool Signed { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TriageClusterStatsResponse
|
||||
{
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public string? Filter { get; init; }
|
||||
public required int TotalClusters { get; init; }
|
||||
public required int TotalFindings { get; init; }
|
||||
public required IReadOnlyList<TriageClusterStatsItem> Clusters { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> SeverityDistribution { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ReachabilityDistribution { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TriageClusterStatsItem
|
||||
{
|
||||
public required string PathId { get; init; }
|
||||
public required int FindingCount { get; init; }
|
||||
public required decimal PriorityScore { get; init; }
|
||||
public required string Reachability { get; init; }
|
||||
public required int Critical { get; init; }
|
||||
public required int High { get; init; }
|
||||
public required int Medium { get; init; }
|
||||
public required int Low { get; init; }
|
||||
}
|
||||
@@ -49,6 +49,9 @@ internal static class TriageInboxEndpoints
|
||||
private static async Task<IResult> HandleGetInboxAsync(
|
||||
[FromQuery] string artifactDigest,
|
||||
[FromQuery] string? filter,
|
||||
[FromQuery] decimal? similarityThreshold,
|
||||
[FromQuery] string? sortBy,
|
||||
[FromQuery] bool descending,
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] IFindingQueryService findingService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
@@ -68,9 +71,11 @@ internal static class TriageInboxEndpoints
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken);
|
||||
var paths = await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
|
||||
var paths = similarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken)
|
||||
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
|
||||
|
||||
var filteredPaths = ApplyFilter(paths, filter);
|
||||
var filteredPaths = ApplySort(ApplyFilter(paths, filter), sortBy, descending);
|
||||
|
||||
var response = new TriageInboxResponse
|
||||
{
|
||||
@@ -103,6 +108,31 @@ internal static class TriageInboxEndpoints
|
||||
_ => paths
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExploitPath> ApplySort(
|
||||
IReadOnlyList<ExploitPath> paths,
|
||||
string? sortBy,
|
||||
bool descending)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sortBy))
|
||||
{
|
||||
return paths.OrderByDescending(static p => p.PriorityScore).ThenBy(static p => p.PathId, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
var ordered = sortBy.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"cluster-size" => paths.OrderBy(static p => p.FindingIds.Length).ThenBy(static p => p.PathId, StringComparer.Ordinal),
|
||||
"severity" => paths.OrderBy(static p => p.RiskScore.CriticalCount)
|
||||
.ThenBy(static p => p.RiskScore.HighCount)
|
||||
.ThenBy(static p => p.RiskScore.MediumCount)
|
||||
.ThenBy(static p => p.PathId, StringComparer.Ordinal),
|
||||
"reachability" => paths.OrderBy(static p => p.Reachability).ThenBy(static p => p.PathId, StringComparer.Ordinal),
|
||||
"priority" => paths.OrderBy(static p => p.PriorityScore).ThenBy(static p => p.PathId, StringComparer.Ordinal),
|
||||
_ => paths.OrderByDescending(static p => p.PriorityScore).ThenBy(static p => p.PathId, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
return descending ? ordered.Reverse().ToArray() : ordered.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -28,6 +28,7 @@ using StellaOps.Scanner.Core.Configuration;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.TrustAnchors;
|
||||
using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.ReachabilityDrift.DependencyInjection;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
@@ -37,6 +38,7 @@ using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.Triage;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Determinism;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -174,6 +176,9 @@ builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
|
||||
builder.Services.AddSingleton<IBaselineService, BaselineService>();
|
||||
builder.Services.AddSingleton<IActionablesService, ActionablesService>();
|
||||
builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiService>();
|
||||
builder.Services.TryAddSingleton<IVexGateResultsStore, InMemoryVexGateResultsStore>();
|
||||
builder.Services.TryAddSingleton<IVexGateQueryService, VexGateQueryService>();
|
||||
builder.Services.TryAddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
|
||||
|
||||
// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE)
|
||||
builder.Services.AddScoped<ISecretDetectionSettingsService, SecretDetectionSettingsService>();
|
||||
@@ -192,6 +197,8 @@ builder.Services.AddDbContext<TriageDbContext>(options =>
|
||||
}));
|
||||
builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
|
||||
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
|
||||
builder.Services.TryAddScoped<IFindingQueryService, FindingQueryService>();
|
||||
builder.Services.TryAddSingleton<IExploitPathGroupingService, ExploitPathGroupingService>();
|
||||
|
||||
// Verdict rationale rendering (Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer)
|
||||
builder.Services.AddVerdictExplainability();
|
||||
@@ -612,6 +619,7 @@ apiGroup.MapWitnessEndpoints(); // Sprint: SPRINT_3700_0001_0001
|
||||
apiGroup.MapEpssEndpoints(); // Sprint: SPRINT_3410_0002_0001
|
||||
apiGroup.MapTriageStatusEndpoints();
|
||||
apiGroup.MapTriageInboxEndpoints();
|
||||
apiGroup.MapBatchTriageEndpoints();
|
||||
apiGroup.MapProofBundleEndpoints();
|
||||
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Scanner.Triage;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads triage findings and maps them into exploit-path grouping inputs.
|
||||
/// </summary>
|
||||
public sealed class FindingQueryService : IFindingQueryService
|
||||
{
|
||||
private readonly TriageDbContext _dbContext;
|
||||
private readonly ILogger<FindingQueryService> _logger;
|
||||
|
||||
public FindingQueryService(TriageDbContext dbContext, ILogger<FindingQueryService> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var findings = await _dbContext.Findings
|
||||
.Include(static f => f.RiskResults)
|
||||
.Include(static f => f.ReachabilityResults)
|
||||
.AsNoTracking()
|
||||
.Where(f => f.ArtifactDigest == artifactDigest)
|
||||
.OrderBy(f => f.Id)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var mapped = findings.Select(MapFinding).ToArray();
|
||||
_logger.LogInformation(
|
||||
"Mapped {FindingCount} findings for artifact {ArtifactDigest} into triage-grouping inputs",
|
||||
mapped.Length,
|
||||
artifactDigest);
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
private static Finding MapFinding(TriageFinding finding)
|
||||
{
|
||||
var latestRisk = finding.RiskResults
|
||||
.OrderByDescending(static r => r.ComputedAt)
|
||||
.FirstOrDefault();
|
||||
var latestReachability = finding.ReachabilityResults
|
||||
.OrderByDescending(static r => r.ComputedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
var cveIds = string.IsNullOrWhiteSpace(finding.CveId)
|
||||
? []
|
||||
: new[] { finding.CveId.Trim() };
|
||||
|
||||
var packageName = ParsePackageName(finding.Purl);
|
||||
var packageVersion = ParsePackageVersion(finding.Purl);
|
||||
var callChain = ParseCallChain(latestReachability?.StaticProofRef, latestReachability?.RuntimeProofRef, packageName);
|
||||
var reachabilityHint = MapReachability(latestReachability);
|
||||
var reachabilityConfidence = latestReachability is null
|
||||
? (decimal?)null
|
||||
: decimal.Round(decimal.Clamp(latestReachability.Confidence / 100m, 0m, 1m), 4, MidpointRounding.ToZero);
|
||||
|
||||
return new Finding(
|
||||
finding.Id.ToString(),
|
||||
finding.Purl,
|
||||
packageName,
|
||||
packageVersion,
|
||||
cveIds,
|
||||
ConvertRiskToCvss(latestRisk),
|
||||
ConvertRiskToEpss(latestRisk),
|
||||
MapSeverity(latestRisk),
|
||||
finding.ArtifactDigest ?? "sha256:unknown",
|
||||
finding.FirstSeenAt,
|
||||
callChain,
|
||||
callChain.Count > 0 ? callChain[0] : null,
|
||||
callChain.Count > 0 ? callChain[^1] : null,
|
||||
reachabilityHint,
|
||||
reachabilityConfidence);
|
||||
}
|
||||
|
||||
private static Severity MapSeverity(TriageRiskResult? risk)
|
||||
=> risk?.Score switch
|
||||
{
|
||||
>= 90 => Severity.Critical,
|
||||
>= 70 => Severity.High,
|
||||
>= 40 => Severity.Medium,
|
||||
>= 10 => Severity.Low,
|
||||
_ => Severity.Info
|
||||
};
|
||||
|
||||
private static decimal ConvertRiskToCvss(TriageRiskResult? risk)
|
||||
{
|
||||
if (risk is null)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var cvss = risk.Score / 10m;
|
||||
return decimal.Round(decimal.Clamp(cvss, 0m, 10m), 2, MidpointRounding.ToZero);
|
||||
}
|
||||
|
||||
private static decimal ConvertRiskToEpss(TriageRiskResult? risk)
|
||||
{
|
||||
if (risk is null)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var epss = risk.Score / 100m;
|
||||
return decimal.Round(decimal.Clamp(epss, 0m, 1m), 4, MidpointRounding.ToZero);
|
||||
}
|
||||
|
||||
private static ReachabilityStatus MapReachability(TriageReachabilityResult? reachability)
|
||||
=> reachability?.Reachable switch
|
||||
{
|
||||
TriageReachability.Yes when !string.IsNullOrWhiteSpace(reachability.RuntimeProofRef) => ReachabilityStatus.RuntimeConfirmed,
|
||||
TriageReachability.Yes => ReachabilityStatus.StaticallyReachable,
|
||||
TriageReachability.No => ReachabilityStatus.Unreachable,
|
||||
_ => ReachabilityStatus.Unknown
|
||||
};
|
||||
|
||||
private static IReadOnlyList<string> ParseCallChain(string? staticProofRef, string? runtimeProofRef, string packageName)
|
||||
{
|
||||
var raw = !string.IsNullOrWhiteSpace(staticProofRef) ? staticProofRef! : runtimeProofRef;
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return [$"entrypoint:{packageName}", $"symbol:{packageName}"];
|
||||
}
|
||||
|
||||
var separators = new[] { "->", "=>", "|" };
|
||||
foreach (var separator in separators)
|
||||
{
|
||||
if (raw.Contains(separator, StringComparison.Ordinal))
|
||||
{
|
||||
var chain = raw.Split(separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (chain.Length > 0)
|
||||
{
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$"entrypoint:{packageName}", raw.Trim()];
|
||||
}
|
||||
|
||||
private static string ParsePackageName(string purl)
|
||||
{
|
||||
var normalized = purl.Trim();
|
||||
var atIndex = normalized.IndexOf('@', StringComparison.Ordinal);
|
||||
if (atIndex > 0)
|
||||
{
|
||||
normalized = normalized[..atIndex];
|
||||
}
|
||||
|
||||
var slash = normalized.LastIndexOf('/');
|
||||
if (slash >= 0 && slash + 1 < normalized.Length)
|
||||
{
|
||||
return normalized[(slash + 1)..];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ParsePackageVersion(string purl)
|
||||
{
|
||||
var atIndex = purl.IndexOf('@', StringComparison.Ordinal);
|
||||
if (atIndex < 0 || atIndex + 1 >= purl.Length)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return purl[(atIndex + 1)..].Trim();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Sarif/StellaOps.Scanner.Sarif.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Validation/StellaOps.Scanner.Validation.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Gate/StellaOps.Scanner.Gate.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -10,3 +10,5 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
||||
| TODO-WEB-003 | TODO | Add VEX expiry once integrated in `src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceCompositionService.cs`. |
|
||||
| PRAGMA-WEB-001 | DONE | Documented ASPDEPR002 suppressions in `src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReportEndpoints.cs`, `src/Scanner/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs`, and `src/Scanner/StellaOps.Scanner.WebService/Endpoints/EpssEndpoints.cs`. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-062-VEXREACH-001 | DONE | Added `POST /api/v1/scans/vex-reachability/filter` endpoint and deterministic matrix annotations for findings (2026-02-08). |
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Implement triage cluster batch action and cluster statistics endpoints for sprint 063 (2026-02-08). |
|
||||
|
||||
Reference in New Issue
Block a user