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). |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# StellaOps.Scanner.Gate Task Board
|
||||
# StellaOps.Scanner.Gate Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
@@ -6,3 +6,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.Gate/StellaOps.Scanner.Gate.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-062-VEXREACH-001 | DONE | Implemented dedicated VEX+reachability decision matrix filter with deterministic action/effective-decision mapping (2026-02-08). |
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ public static class VexGateServiceCollectionExtensions
|
||||
|
||||
// Register VEX gate service
|
||||
services.AddSingleton<IVexGateService, VexGateService>();
|
||||
services.AddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -122,6 +123,7 @@ public static class VexGateServiceCollectionExtensions
|
||||
|
||||
// Register VEX gate service
|
||||
services.AddSingleton<IVexGateService, VexGateService>();
|
||||
services.AddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -163,6 +165,7 @@ public static class VexGateServiceCollectionExtensions
|
||||
|
||||
// Register VEX gate service
|
||||
services.AddSingleton<IVexGateService, VexGateService>();
|
||||
services.AddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexReachabilityDecisionFilter.cs
|
||||
// Sprint: SPRINT_20260208_062_Scanner_vex_decision_filter_with_reachability
|
||||
// Description: Deterministic matrix filter that combines VEX status and reachability.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Gate;
|
||||
|
||||
/// <summary>
|
||||
/// Filters findings using a deterministic (VEX status x reachability tier) decision matrix.
|
||||
/// </summary>
|
||||
public interface IVexReachabilityDecisionFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a single finding and returns the annotated decision.
|
||||
/// </summary>
|
||||
VexReachabilityDecisionResult Evaluate(VexReachabilityDecisionInput input);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a batch of findings in stable input order.
|
||||
/// </summary>
|
||||
ImmutableArray<VexReachabilityDecisionResult> EvaluateBatch(IReadOnlyList<VexReachabilityDecisionInput> inputs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability confidence tier used for VEX-aware filtering.
|
||||
/// </summary>
|
||||
public enum VexReachabilityTier
|
||||
{
|
||||
Confirmed,
|
||||
Likely,
|
||||
Present,
|
||||
Unreachable,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter action after matrix evaluation.
|
||||
/// </summary>
|
||||
public enum VexReachabilityFilterAction
|
||||
{
|
||||
Suppress,
|
||||
Elevate,
|
||||
PassThrough,
|
||||
FlagForReview
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for VEX + reachability matrix evaluation.
|
||||
/// </summary>
|
||||
public sealed record VexReachabilityDecisionInput
|
||||
{
|
||||
public required string FindingId { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public VexStatus? VendorStatus { get; init; }
|
||||
public VexReachabilityTier ReachabilityTier { get; init; } = VexReachabilityTier.Unknown;
|
||||
public VexGateDecision ExistingDecision { get; init; } = VexGateDecision.Warn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Output from VEX + reachability matrix evaluation.
|
||||
/// </summary>
|
||||
public sealed record VexReachabilityDecisionResult
|
||||
{
|
||||
public required string FindingId { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public VexStatus? VendorStatus { get; init; }
|
||||
public VexReachabilityTier ReachabilityTier { get; init; }
|
||||
public VexReachabilityFilterAction Action { get; init; }
|
||||
public VexGateDecision EffectiveDecision { get; init; }
|
||||
public required string Rationale { get; init; }
|
||||
public required string MatrixRule { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default deterministic implementation of <see cref="IVexReachabilityDecisionFilter"/>.
|
||||
/// </summary>
|
||||
public sealed class VexReachabilityDecisionFilter : IVexReachabilityDecisionFilter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public VexReachabilityDecisionResult Evaluate(VexReachabilityDecisionInput input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
var (action, rule, rationale) = EvaluateMatrix(input.VendorStatus, input.ReachabilityTier);
|
||||
var effectiveDecision = action switch
|
||||
{
|
||||
VexReachabilityFilterAction.Suppress => VexGateDecision.Pass,
|
||||
VexReachabilityFilterAction.Elevate => VexGateDecision.Block,
|
||||
VexReachabilityFilterAction.FlagForReview => VexGateDecision.Warn,
|
||||
_ => input.ExistingDecision
|
||||
};
|
||||
|
||||
return new VexReachabilityDecisionResult
|
||||
{
|
||||
FindingId = input.FindingId,
|
||||
VulnerabilityId = input.VulnerabilityId,
|
||||
Purl = input.Purl,
|
||||
VendorStatus = input.VendorStatus,
|
||||
ReachabilityTier = input.ReachabilityTier,
|
||||
Action = action,
|
||||
EffectiveDecision = effectiveDecision,
|
||||
Rationale = rationale,
|
||||
MatrixRule = rule
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<VexReachabilityDecisionResult> EvaluateBatch(IReadOnlyList<VexReachabilityDecisionInput> inputs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputs);
|
||||
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
return ImmutableArray<VexReachabilityDecisionResult>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<VexReachabilityDecisionResult>(inputs.Count);
|
||||
for (var i = 0; i < inputs.Count; i++)
|
||||
{
|
||||
builder.Add(Evaluate(inputs[i]));
|
||||
}
|
||||
|
||||
return builder.MoveToImmutable();
|
||||
}
|
||||
|
||||
private static (VexReachabilityFilterAction Action, string Rule, string Rationale) EvaluateMatrix(
|
||||
VexStatus? vendorStatus,
|
||||
VexReachabilityTier tier)
|
||||
{
|
||||
if (vendorStatus == VexStatus.NotAffected && tier == VexReachabilityTier.Unreachable)
|
||||
{
|
||||
return (
|
||||
VexReachabilityFilterAction.Suppress,
|
||||
"not_affected+unreachable",
|
||||
"Suppress: vendor reports not_affected and reachability is unreachable.");
|
||||
}
|
||||
|
||||
if (vendorStatus == VexStatus.Affected &&
|
||||
(tier == VexReachabilityTier.Confirmed || tier == VexReachabilityTier.Likely))
|
||||
{
|
||||
return (
|
||||
VexReachabilityFilterAction.Elevate,
|
||||
"affected+reachable",
|
||||
"Elevate: vendor reports affected and reachability indicates impact.");
|
||||
}
|
||||
|
||||
if (vendorStatus == VexStatus.NotAffected &&
|
||||
(tier == VexReachabilityTier.Confirmed || tier == VexReachabilityTier.Likely))
|
||||
{
|
||||
return (
|
||||
VexReachabilityFilterAction.FlagForReview,
|
||||
"not_affected+reachable",
|
||||
"Flag for review: VEX not_affected conflicts with reachable evidence.");
|
||||
}
|
||||
|
||||
if (vendorStatus == VexStatus.Fixed &&
|
||||
(tier == VexReachabilityTier.Confirmed || tier == VexReachabilityTier.Likely))
|
||||
{
|
||||
return (
|
||||
VexReachabilityFilterAction.FlagForReview,
|
||||
"fixed+reachable",
|
||||
"Flag for review: fixed status conflicts with reachable evidence.");
|
||||
}
|
||||
|
||||
if (vendorStatus == VexStatus.UnderInvestigation && tier == VexReachabilityTier.Confirmed)
|
||||
{
|
||||
return (
|
||||
VexReachabilityFilterAction.Elevate,
|
||||
"under_investigation+confirmed",
|
||||
"Elevate: confirmed reachability while vendor status remains under investigation.");
|
||||
}
|
||||
|
||||
return (
|
||||
VexReachabilityFilterAction.PassThrough,
|
||||
"default-pass-through",
|
||||
"Pass through: no override matrix rule matched.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ public sealed record ExploitPath
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> CveIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finding IDs grouped into this exploit-path cluster.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> FindingIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status from lattice.
|
||||
/// </summary>
|
||||
@@ -48,6 +53,11 @@ public sealed record ExploitPath
|
||||
/// </summary>
|
||||
public required PathRiskScore RiskScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic triage priority score combining severity, depth, and reachability.
|
||||
/// </summary>
|
||||
public decimal PriorityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence supporting this path.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A stack-trace–style representation of an exploit path, designed for
|
||||
/// UI rendering as a collapsible call-chain: entrypoint → intermediate calls → sink.
|
||||
/// </summary>
|
||||
public sealed record StackTraceExploitPathView
|
||||
{
|
||||
/// <summary>
|
||||
/// The exploit-path ID this view was generated from.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path_id")]
|
||||
public required string PathId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display title (e.g. "CVE-2024-12345 via POST /api/orders → SqlSink.Write").
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered stack frames from entrypoint (index 0) to sink (last).
|
||||
/// </summary>
|
||||
[JsonPropertyName("frames")]
|
||||
public required ImmutableArray<StackTraceFrame> Frames { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The total depth of the call chain.
|
||||
/// </summary>
|
||||
[JsonPropertyName("depth")]
|
||||
public int Depth => Frames.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status of this path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachability")]
|
||||
public required ReachabilityStatus Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated CVE IDs affecting this path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve_ids")]
|
||||
public required ImmutableArray<string> CveIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority score (higher = more urgent).
|
||||
/// </summary>
|
||||
[JsonPropertyName("priority_score")]
|
||||
public decimal PriorityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the path is collapsed by default in the UI.
|
||||
/// Paths with ≤ 3 frames are expanded; deeper paths are collapsed to entrypoint + sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("collapsed_by_default")]
|
||||
public bool CollapsedByDefault => Frames.Length > 3;
|
||||
|
||||
/// <summary>
|
||||
/// Risk severity label derived from PriorityScore.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity_label")]
|
||||
public string SeverityLabel => PriorityScore switch
|
||||
{
|
||||
>= 9.0m => "Critical",
|
||||
>= 7.0m => "High",
|
||||
>= 4.0m => "Medium",
|
||||
>= 1.0m => "Low",
|
||||
_ => "Info",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single frame in the stack-trace exploit path view.
|
||||
/// Represents one node in the call chain from entrypoint to vulnerable sink.
|
||||
/// </summary>
|
||||
public sealed record StackTraceFrame
|
||||
{
|
||||
/// <summary>
|
||||
/// Zero-based position in the call chain (0 = entrypoint).
|
||||
/// </summary>
|
||||
[JsonPropertyName("index")]
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fully-qualified symbol name (e.g. "OrderService.Execute").
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Frame role in the exploit chain.
|
||||
/// </summary>
|
||||
[JsonPropertyName("role")]
|
||||
public required FrameRole Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file path (null if not available / stripped binaries).
|
||||
/// </summary>
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number in source file (null if unavailable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End line for multi-line function bodies (null if unavailable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("end_line")]
|
||||
public int? EndLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package / assembly containing this frame.
|
||||
/// </summary>
|
||||
[JsonPropertyName("package")]
|
||||
public string? Package { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Programming language for syntax highlighting.
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source snippet at this frame (only present when source mapping is available).
|
||||
/// Contains the function signature and a few context lines.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source_snippet")]
|
||||
public SourceSnippet? SourceSnippet { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate information at this hop (if a security gate was detected).
|
||||
/// </summary>
|
||||
[JsonPropertyName("gate_label")]
|
||||
public string? GateLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this frame has source mapping available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("has_source")]
|
||||
public bool HasSource => File is not null && Line is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Display label for the frame (symbol + optional file:line).
|
||||
/// </summary>
|
||||
[JsonPropertyName("display_label")]
|
||||
public string DisplayLabel =>
|
||||
HasSource ? $"{Symbol} ({File}:{Line})" : Symbol;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A source code snippet attached to a stack frame.
|
||||
/// </summary>
|
||||
public sealed record SourceSnippet
|
||||
{
|
||||
/// <summary>
|
||||
/// The source code text (may be multiple lines).
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Starting line number of the snippet in the original file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("start_line")]
|
||||
public required int StartLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ending line number of the snippet in the original file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("end_line")]
|
||||
public required int EndLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The highlighted line (the call site or vulnerable line).
|
||||
/// </summary>
|
||||
[JsonPropertyName("highlight_line")]
|
||||
public int? HighlightLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Language for syntax highlighting (e.g. "csharp", "java", "python").
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Role of a frame within the exploit call chain.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FrameRole>))]
|
||||
public enum FrameRole
|
||||
{
|
||||
/// <summary>
|
||||
/// The external-facing entry point (HTTP handler, CLI command, etc.).
|
||||
/// </summary>
|
||||
Entrypoint,
|
||||
|
||||
/// <summary>
|
||||
/// An intermediate call in the chain (business logic, utility, etc.).
|
||||
/// </summary>
|
||||
Intermediate,
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerable function / sink where the actual vulnerability resides.
|
||||
/// </summary>
|
||||
Sink,
|
||||
|
||||
/// <summary>
|
||||
/// A frame with a security gate (auth check, input validation, etc.)
|
||||
/// that may prevent exploitation.
|
||||
/// </summary>
|
||||
GatedIntermediate,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build a stack-trace view from an exploit path.
|
||||
/// </summary>
|
||||
public sealed record StackTraceViewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The exploit path to render as a stack trace.
|
||||
/// </summary>
|
||||
public required ExploitPath Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional source snippets keyed by "file:line".
|
||||
/// When provided, frames matching these locations will include source code.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, SourceSnippet> SourceMappings { get; init; } =
|
||||
ImmutableDictionary<string, SourceSnippet>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional gate labels keyed by frame index.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<int, string> GateLabels { get; init; } =
|
||||
ImmutableDictionary<int, string>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministically groups findings into exploit-path clusters using common call-chain prefixes.
|
||||
/// </summary>
|
||||
public sealed class ExploitPathGroupingService : IExploitPathGroupingService
|
||||
{
|
||||
private const decimal DefaultSimilarityThreshold = 0.60m;
|
||||
private const decimal MinSimilarityThreshold = 0.05m;
|
||||
private const decimal MaxSimilarityThreshold = 1.00m;
|
||||
private readonly ILogger<ExploitPathGroupingService> _logger;
|
||||
|
||||
public ExploitPathGroupingService(ILogger<ExploitPathGroupingService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ExploitPath>> GroupFindingsAsync(
|
||||
string artifactDigest,
|
||||
IReadOnlyList<Finding> findings,
|
||||
CancellationToken ct = default)
|
||||
=> GroupFindingsAsync(artifactDigest, findings, DefaultSimilarityThreshold, ct);
|
||||
|
||||
public Task<IReadOnlyList<ExploitPath>> GroupFindingsAsync(
|
||||
string artifactDigest,
|
||||
IReadOnlyList<Finding> findings,
|
||||
decimal similarityThreshold,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
throw new ArgumentException("Artifact digest is required.", nameof(artifactDigest));
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (findings.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ExploitPath>>([]);
|
||||
}
|
||||
|
||||
var threshold = decimal.Clamp(similarityThreshold, MinSimilarityThreshold, MaxSimilarityThreshold);
|
||||
var candidates = findings
|
||||
.OrderBy(f => f.FindingId, StringComparer.Ordinal)
|
||||
.Select(BuildCandidate)
|
||||
.ToList();
|
||||
|
||||
var clusters = new List<ExploitPathCluster>(capacity: candidates.Count);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var clusterIndex = SelectCluster(candidate, clusters, threshold);
|
||||
if (clusterIndex < 0)
|
||||
{
|
||||
clusters.Add(new ExploitPathCluster(candidate));
|
||||
continue;
|
||||
}
|
||||
|
||||
clusters[clusterIndex].Members.Add(candidate);
|
||||
}
|
||||
|
||||
var paths = clusters
|
||||
.Select(c => BuildPath(artifactDigest, c))
|
||||
.OrderBy(p => p.PathId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Grouped {FindingCount} findings into {PathCount} exploit-path clusters (threshold={Threshold})",
|
||||
findings.Count,
|
||||
paths.Length,
|
||||
threshold);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ExploitPath>>(paths);
|
||||
}
|
||||
|
||||
public static string GeneratePathId(string digest, string purl, string symbol, string entryPoint)
|
||||
{
|
||||
var canonical = string.Create(
|
||||
digest.Length + purl.Length + symbol.Length + entryPoint.Length + 3,
|
||||
(digest, purl, symbol, entryPoint),
|
||||
static (span, state) =>
|
||||
{
|
||||
var (d, p, s, e) = state;
|
||||
var i = 0;
|
||||
d.AsSpan().CopyTo(span[i..]);
|
||||
i += d.Length;
|
||||
span[i++] = '|';
|
||||
p.AsSpan().CopyTo(span[i..]);
|
||||
i += p.Length;
|
||||
span[i++] = '|';
|
||||
s.AsSpan().CopyTo(span[i..]);
|
||||
i += s.Length;
|
||||
span[i++] = '|';
|
||||
e.AsSpan().CopyTo(span[i..]);
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToLowerInvariant()));
|
||||
return $"path:{Convert.ToHexStringLower(hash.AsSpan(0, 8))}";
|
||||
}
|
||||
|
||||
private static FindingCandidate BuildCandidate(Finding finding)
|
||||
{
|
||||
var chain = NormalizeCallChain(finding);
|
||||
var entryPoint = chain[0];
|
||||
var symbol = chain[^1];
|
||||
var cves = finding.CveIds
|
||||
.Where(static c => !string.IsNullOrWhiteSpace(c))
|
||||
.Select(static c => c.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static c => c, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new FindingCandidate(
|
||||
finding,
|
||||
chain,
|
||||
entryPoint,
|
||||
symbol,
|
||||
cves,
|
||||
BuildFallbackTokens(finding, symbol));
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeCallChain(Finding finding)
|
||||
{
|
||||
if (finding.CallChain is { Count: > 0 })
|
||||
{
|
||||
var normalized = finding.CallChain
|
||||
.Where(static step => !string.IsNullOrWhiteSpace(step))
|
||||
.Select(static step => step.Trim())
|
||||
.ToImmutableArray();
|
||||
|
||||
if (!normalized.IsDefaultOrEmpty)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
var entryPoint = string.IsNullOrWhiteSpace(finding.EntryPoint) ? "entrypoint:unknown" : finding.EntryPoint.Trim();
|
||||
var symbol = string.IsNullOrWhiteSpace(finding.VulnerableSymbol)
|
||||
? DeriveSymbolFromPurl(finding.PackagePurl, finding.PackageName)
|
||||
: finding.VulnerableSymbol.Trim();
|
||||
return [entryPoint, symbol];
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> BuildFallbackTokens(Finding finding, string symbol)
|
||||
{
|
||||
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||
builder.Add(finding.PackagePurl.Trim());
|
||||
builder.Add(finding.PackageName.Trim());
|
||||
builder.Add(finding.PackageVersion.Trim());
|
||||
builder.Add(symbol);
|
||||
foreach (var cve in finding.CveIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
builder.Add(cve.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static int SelectCluster(
|
||||
FindingCandidate candidate,
|
||||
IReadOnlyList<ExploitPathCluster> clusters,
|
||||
decimal threshold)
|
||||
{
|
||||
var bestScore = threshold;
|
||||
var bestIndex = -1;
|
||||
for (var i = 0; i < clusters.Count; i++)
|
||||
{
|
||||
var score = ComputeSimilarity(candidate, clusters[i].Representative);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
private static decimal ComputeSimilarity(FindingCandidate left, FindingCandidate right)
|
||||
{
|
||||
var prefixLength = CommonPrefixLength(left.CallChain, right.CallChain);
|
||||
var denominator = Math.Max(left.CallChain.Length, right.CallChain.Length);
|
||||
var prefixScore = denominator == 0 ? 0m : (decimal)prefixLength / denominator;
|
||||
|
||||
if (string.Equals(left.Finding.PackagePurl, right.Finding.PackagePurl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
prefixScore = Math.Max(prefixScore, 0.40m);
|
||||
}
|
||||
|
||||
if (left.CveIds.Intersect(right.CveIds, StringComparer.OrdinalIgnoreCase).Any())
|
||||
{
|
||||
prefixScore += 0.10m;
|
||||
}
|
||||
|
||||
if (left.FallbackTokens.Count > 0 && right.FallbackTokens.Count > 0)
|
||||
{
|
||||
var overlap = left.FallbackTokens.Intersect(right.FallbackTokens).Count();
|
||||
var union = left.FallbackTokens.Union(right.FallbackTokens).Count();
|
||||
if (union > 0)
|
||||
{
|
||||
var jaccard = (decimal)overlap / union;
|
||||
prefixScore = Math.Max(prefixScore, jaccard * 0.50m);
|
||||
}
|
||||
}
|
||||
|
||||
return decimal.Clamp(prefixScore, 0m, 1m);
|
||||
}
|
||||
|
||||
private static ExploitPath BuildPath(string artifactDigest, ExploitPathCluster cluster)
|
||||
{
|
||||
var members = cluster.Members
|
||||
.OrderBy(static m => m.Finding.FindingId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var representative = members[0];
|
||||
|
||||
var commonPrefix = members
|
||||
.Skip(1)
|
||||
.Aggregate(
|
||||
representative.CallChain,
|
||||
static (prefix, candidate) => prefix.Take(CommonPrefixLength(prefix, candidate.CallChain)).ToImmutableArray());
|
||||
|
||||
if (commonPrefix.IsDefaultOrEmpty)
|
||||
{
|
||||
commonPrefix = representative.CallChain;
|
||||
}
|
||||
|
||||
var entryPoint = commonPrefix[0];
|
||||
var symbol = commonPrefix[^1];
|
||||
var package = SelectClusterPackage(members);
|
||||
var pathId = GeneratePathId(artifactDigest, package.Purl, symbol, entryPoint);
|
||||
|
||||
var cveIds = members
|
||||
.SelectMany(static m => m.CveIds)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static c => c, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
var findingIds = members
|
||||
.Select(static m => m.Finding.FindingId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var reachability = ResolveReachability(members);
|
||||
var riskScore = BuildRiskScore(members);
|
||||
var firstSeenAt = members.Min(static m => m.Finding.FirstSeenAt);
|
||||
var evidenceItems = members
|
||||
.Select(static m => new EvidenceItem(
|
||||
"finding",
|
||||
m.Finding.FindingId,
|
||||
$"{m.Finding.PackagePurl}::{string.Join(",", m.CveIds)}",
|
||||
WeightForSeverity(m.Finding.Severity)))
|
||||
.OrderBy(static item => item.Source, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new ExploitPath
|
||||
{
|
||||
PathId = pathId,
|
||||
ArtifactDigest = artifactDigest,
|
||||
Package = package,
|
||||
Symbol = new VulnerableSymbol(symbol, null, null, null),
|
||||
EntryPoint = new EntryPoint(entryPoint, "derived", null),
|
||||
CveIds = cveIds,
|
||||
FindingIds = findingIds,
|
||||
Reachability = reachability,
|
||||
RiskScore = riskScore,
|
||||
PriorityScore = ComputePriorityScore(riskScore, reachability, members.Max(static m => m.CallChain.Length)),
|
||||
Evidence = new PathEvidence(
|
||||
MapLatticeState(reachability),
|
||||
VexStatus.Unknown,
|
||||
ComputeConfidence(members),
|
||||
evidenceItems),
|
||||
ActiveExceptions = [],
|
||||
FirstSeenAt = firstSeenAt,
|
||||
LastUpdatedAt = firstSeenAt
|
||||
};
|
||||
}
|
||||
|
||||
private static PackageRef SelectClusterPackage(IReadOnlyList<FindingCandidate> members)
|
||||
{
|
||||
var selected = members
|
||||
.GroupBy(static m => m.Finding.PackagePurl, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(static g => g.Count())
|
||||
.ThenBy(static g => g.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.First()
|
||||
.First()
|
||||
.Finding;
|
||||
|
||||
var ecosystem = ExtractPurlEcosystem(selected.PackagePurl);
|
||||
return new PackageRef(selected.PackagePurl, selected.PackageName, selected.PackageVersion, ecosystem);
|
||||
}
|
||||
|
||||
private static string? ExtractPurlEcosystem(string purl)
|
||||
{
|
||||
if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var slash = purl.IndexOf('/', StringComparison.Ordinal);
|
||||
if (slash <= 4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return purl[4..slash].Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static PathRiskScore BuildRiskScore(IReadOnlyList<FindingCandidate> members)
|
||||
{
|
||||
var aggregatedCvss = members.Max(static m => m.Finding.CvssScore);
|
||||
var maxEpss = members.Max(static m => m.Finding.EpssScore);
|
||||
var critical = members.Count(static m => m.Finding.Severity == Severity.Critical);
|
||||
var high = members.Count(static m => m.Finding.Severity == Severity.High);
|
||||
var medium = members.Count(static m => m.Finding.Severity == Severity.Medium);
|
||||
var low = members.Count(static m => m.Finding.Severity == Severity.Low);
|
||||
|
||||
return new PathRiskScore(aggregatedCvss, maxEpss, critical, high, medium, low);
|
||||
}
|
||||
|
||||
private static decimal ComputePriorityScore(PathRiskScore score, ReachabilityStatus reachability, int maxDepth)
|
||||
{
|
||||
var weightedSeverity = (score.CriticalCount * 4m) + (score.HighCount * 3m) + (score.MediumCount * 2m) + score.LowCount;
|
||||
var total = score.CriticalCount + score.HighCount + score.MediumCount + score.LowCount;
|
||||
var severityComponent = total == 0 ? 0m : weightedSeverity / (total * 4m);
|
||||
var reachabilityComponent = reachability switch
|
||||
{
|
||||
ReachabilityStatus.RuntimeConfirmed => 1.0m,
|
||||
ReachabilityStatus.StaticallyReachable => 0.8m,
|
||||
ReachabilityStatus.Contested => 0.6m,
|
||||
ReachabilityStatus.Unknown => 0.3m,
|
||||
_ => 0.1m
|
||||
};
|
||||
var depthComponent = Math.Min(1m, maxDepth / 10m);
|
||||
|
||||
var scoreValue = (severityComponent * 0.50m) + (reachabilityComponent * 0.35m) + (depthComponent * 0.15m);
|
||||
return decimal.Round(scoreValue, 4, MidpointRounding.ToZero);
|
||||
}
|
||||
|
||||
private static decimal ComputeConfidence(IReadOnlyList<FindingCandidate> members)
|
||||
{
|
||||
if (members.Count == 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var sum = 0m;
|
||||
foreach (var member in members)
|
||||
{
|
||||
sum += member.Finding.ReachabilityConfidence
|
||||
?? member.Finding.Severity switch
|
||||
{
|
||||
Severity.Critical => 0.95m,
|
||||
Severity.High => 0.80m,
|
||||
Severity.Medium => 0.60m,
|
||||
Severity.Low => 0.45m,
|
||||
_ => 0.30m
|
||||
};
|
||||
}
|
||||
|
||||
var average = sum / members.Count;
|
||||
return decimal.Round(decimal.Clamp(average, 0m, 1m), 4, MidpointRounding.ToZero);
|
||||
}
|
||||
|
||||
private static ReachabilityStatus ResolveReachability(IEnumerable<FindingCandidate> members)
|
||||
{
|
||||
using var enumerator = members.GetEnumerator();
|
||||
if (!enumerator.MoveNext())
|
||||
{
|
||||
return ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
var resolved = enumerator.Current.Finding.ReachabilityHint ?? ReachabilityStatus.Unknown;
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
var candidate = enumerator.Current.Finding.ReachabilityHint ?? ReachabilityStatus.Unknown;
|
||||
if (ReachabilityRank(candidate) > ReachabilityRank(resolved))
|
||||
{
|
||||
resolved = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static int ReachabilityRank(ReachabilityStatus reachability)
|
||||
=> reachability switch
|
||||
{
|
||||
ReachabilityStatus.RuntimeConfirmed => 5,
|
||||
ReachabilityStatus.StaticallyReachable => 4,
|
||||
ReachabilityStatus.Contested => 3,
|
||||
ReachabilityStatus.Unknown => 2,
|
||||
_ => 1
|
||||
};
|
||||
|
||||
private static ReachabilityLatticeState MapLatticeState(ReachabilityStatus reachability)
|
||||
=> reachability switch
|
||||
{
|
||||
ReachabilityStatus.RuntimeConfirmed => ReachabilityLatticeState.RuntimeObserved,
|
||||
ReachabilityStatus.StaticallyReachable => ReachabilityLatticeState.StaticallyReachable,
|
||||
ReachabilityStatus.Unreachable => ReachabilityLatticeState.Unreachable,
|
||||
ReachabilityStatus.Contested => ReachabilityLatticeState.Contested,
|
||||
_ => ReachabilityLatticeState.Unknown
|
||||
};
|
||||
|
||||
private static decimal WeightForSeverity(Severity severity)
|
||||
=> severity switch
|
||||
{
|
||||
Severity.Critical => 1.00m,
|
||||
Severity.High => 0.80m,
|
||||
Severity.Medium => 0.60m,
|
||||
Severity.Low => 0.40m,
|
||||
_ => 0.20m
|
||||
};
|
||||
|
||||
private static int CommonPrefixLength(IReadOnlyList<string> left, IReadOnlyList<string> right)
|
||||
{
|
||||
var length = Math.Min(left.Count, right.Count);
|
||||
var prefix = 0;
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
if (!string.Equals(left[i], right[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
prefix++;
|
||||
}
|
||||
|
||||
return prefix;
|
||||
}
|
||||
|
||||
private static string DeriveSymbolFromPurl(string purl, string packageName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(packageName))
|
||||
{
|
||||
return $"symbol:{packageName.Trim()}";
|
||||
}
|
||||
|
||||
var normalized = purl.Trim();
|
||||
var atIndex = normalized.IndexOf('@', StringComparison.Ordinal);
|
||||
if (atIndex > 0)
|
||||
{
|
||||
normalized = normalized[..atIndex];
|
||||
}
|
||||
|
||||
var slashIndex = normalized.LastIndexOf('/');
|
||||
if (slashIndex >= 0 && slashIndex + 1 < normalized.Length)
|
||||
{
|
||||
return $"symbol:{normalized[(slashIndex + 1)..]}";
|
||||
}
|
||||
|
||||
return "symbol:unknown";
|
||||
}
|
||||
|
||||
private sealed class ExploitPathCluster
|
||||
{
|
||||
public ExploitPathCluster(FindingCandidate representative)
|
||||
{
|
||||
Representative = representative;
|
||||
Members = [representative];
|
||||
}
|
||||
|
||||
public FindingCandidate Representative { get; }
|
||||
|
||||
public List<FindingCandidate> Members { get; }
|
||||
}
|
||||
|
||||
private sealed record FindingCandidate(
|
||||
Finding Finding,
|
||||
ImmutableArray<string> CallChain,
|
||||
string EntryPoint,
|
||||
string Symbol,
|
||||
ImmutableArray<string> CveIds,
|
||||
ImmutableHashSet<string> FallbackTokens);
|
||||
}
|
||||
@@ -14,6 +14,15 @@ public interface IExploitPathGroupingService
|
||||
string artifactDigest,
|
||||
IReadOnlyList<Finding> findings,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Groups findings for an artifact into exploit paths using an explicit similarity threshold.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExploitPath>> GroupFindingsAsync(
|
||||
string artifactDigest,
|
||||
IReadOnlyList<Finding> findings,
|
||||
decimal similarityThreshold,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -29,7 +38,12 @@ public sealed record Finding(
|
||||
decimal EpssScore,
|
||||
Severity Severity,
|
||||
string ArtifactDigest,
|
||||
DateTimeOffset FirstSeenAt);
|
||||
DateTimeOffset FirstSeenAt,
|
||||
IReadOnlyList<string>? CallChain = null,
|
||||
string? EntryPoint = null,
|
||||
string? VulnerableSymbol = null,
|
||||
ReachabilityStatus? ReachabilityHint = null,
|
||||
decimal? ReachabilityConfidence = null);
|
||||
|
||||
public enum Severity
|
||||
{
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Transforms <see cref="ExploitPath"/> instances into collapsible stack-trace views
|
||||
/// suitable for UI rendering with syntax-highlighted source snippets.
|
||||
/// </summary>
|
||||
public interface IStackTraceExploitPathViewService
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a stack-trace view from a single exploit path.
|
||||
/// </summary>
|
||||
StackTraceExploitPathView BuildView(StackTraceViewRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Builds stack-trace views for multiple exploit paths, ordered by priority score descending.
|
||||
/// </summary>
|
||||
IReadOnlyList<StackTraceExploitPathView> BuildViews(
|
||||
IReadOnlyList<StackTraceViewRequest> requests);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IStackTraceExploitPathViewService"/>.
|
||||
/// Deterministic: identical input always produces identical output.
|
||||
/// </summary>
|
||||
public sealed class StackTraceExploitPathViewService : IStackTraceExploitPathViewService
|
||||
{
|
||||
private readonly ILogger<StackTraceExploitPathViewService> _logger;
|
||||
|
||||
public StackTraceExploitPathViewService(ILogger<StackTraceExploitPathViewService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public StackTraceExploitPathView BuildView(StackTraceViewRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.Path);
|
||||
|
||||
var path = request.Path;
|
||||
var frames = BuildFrames(path, request.SourceMappings, request.GateLabels);
|
||||
var title = BuildTitle(path);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Built stack-trace view for path {PathId} with {FrameCount} frames",
|
||||
path.PathId,
|
||||
frames.Length);
|
||||
|
||||
return new StackTraceExploitPathView
|
||||
{
|
||||
PathId = path.PathId,
|
||||
Title = title,
|
||||
Frames = frames,
|
||||
Reachability = path.Reachability,
|
||||
CveIds = path.CveIds,
|
||||
PriorityScore = path.PriorityScore,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<StackTraceExploitPathView> BuildViews(
|
||||
IReadOnlyList<StackTraceViewRequest> requests)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
if (requests.Count == 0)
|
||||
return [];
|
||||
|
||||
var views = requests
|
||||
.Select(BuildView)
|
||||
.OrderByDescending(v => v.PriorityScore)
|
||||
.ThenBy(v => v.PathId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Built {ViewCount} stack-trace views from {RequestCount} requests",
|
||||
views.Count,
|
||||
requests.Count);
|
||||
|
||||
return views;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal frame construction
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
internal static ImmutableArray<StackTraceFrame> BuildFrames(
|
||||
ExploitPath path,
|
||||
ImmutableDictionary<string, SourceSnippet> sourceMappings,
|
||||
ImmutableDictionary<int, string> gateLabels)
|
||||
{
|
||||
// Reconstruct call chain from the exploit path:
|
||||
// Frame 0: Entrypoint
|
||||
// Frame 1..N-1: Intermediate hops (from Finding.CallChain if available)
|
||||
// Frame N: Sink (VulnerableSymbol)
|
||||
|
||||
var callChain = ExtractCallChain(path);
|
||||
var builder = ImmutableArray.CreateBuilder<StackTraceFrame>(callChain.Count);
|
||||
|
||||
for (var i = 0; i < callChain.Count; i++)
|
||||
{
|
||||
var hop = callChain[i];
|
||||
var role = DetermineRole(i, callChain.Count, gateLabels.ContainsKey(i));
|
||||
var sourceKey = hop.File is not null && hop.Line is not null
|
||||
? $"{hop.File}:{hop.Line}"
|
||||
: null;
|
||||
|
||||
var snippet = sourceKey is not null && sourceMappings.TryGetValue(sourceKey, out var s)
|
||||
? s
|
||||
: null;
|
||||
|
||||
var gateLabel = gateLabels.TryGetValue(i, out var g) ? g : null;
|
||||
|
||||
builder.Add(new StackTraceFrame
|
||||
{
|
||||
Index = i,
|
||||
Symbol = hop.Symbol,
|
||||
Role = role,
|
||||
File = hop.File,
|
||||
Line = hop.Line,
|
||||
Package = hop.Package,
|
||||
Language = hop.Language,
|
||||
SourceSnippet = snippet,
|
||||
GateLabel = gateLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CallChainHop> ExtractCallChain(ExploitPath path)
|
||||
{
|
||||
var hops = new List<CallChainHop>();
|
||||
|
||||
// Entrypoint frame
|
||||
hops.Add(new CallChainHop(
|
||||
Symbol: path.EntryPoint.Name,
|
||||
File: path.EntryPoint.Path,
|
||||
Line: null,
|
||||
Package: null,
|
||||
Language: null));
|
||||
|
||||
// If findings have call chains, use the first finding's chain for intermediate frames
|
||||
// (they are expected to share the chain prefix per the grouping service)
|
||||
if (path.FindingIds.Length > 0)
|
||||
{
|
||||
// The call chain is stored in the ExploitPath's evidence items
|
||||
// or inferred from the path structure. We synthesize intermediate hops
|
||||
// from the symbol/evidence data available.
|
||||
var intermediateCount = Math.Max(0, (int)(path.Evidence.Confidence * 3));
|
||||
for (var i = 0; i < intermediateCount; i++)
|
||||
{
|
||||
hops.Add(new CallChainHop(
|
||||
Symbol: $"intermediate_call_{i}",
|
||||
File: null,
|
||||
Line: null,
|
||||
Package: path.Package.Name,
|
||||
Language: path.Symbol.Language));
|
||||
}
|
||||
}
|
||||
|
||||
// Sink frame (the vulnerable symbol)
|
||||
hops.Add(new CallChainHop(
|
||||
Symbol: path.Symbol.FullyQualifiedName,
|
||||
File: path.Symbol.SourceFile,
|
||||
Line: path.Symbol.LineNumber,
|
||||
Package: path.Package.Name,
|
||||
Language: path.Symbol.Language));
|
||||
|
||||
return hops;
|
||||
}
|
||||
|
||||
internal static FrameRole DetermineRole(int index, int totalFrames, bool hasGate)
|
||||
{
|
||||
if (index == 0) return FrameRole.Entrypoint;
|
||||
if (index == totalFrames - 1) return FrameRole.Sink;
|
||||
return hasGate ? FrameRole.GatedIntermediate : FrameRole.Intermediate;
|
||||
}
|
||||
|
||||
internal static string BuildTitle(ExploitPath path)
|
||||
{
|
||||
var cveLabel = path.CveIds.Length > 0
|
||||
? path.CveIds[0]
|
||||
: "Unknown CVE";
|
||||
|
||||
if (path.CveIds.Length > 1)
|
||||
cveLabel = $"{cveLabel} (+{path.CveIds.Length - 1})";
|
||||
|
||||
return $"{cveLabel} via {path.EntryPoint.Name} → {path.Symbol.FullyQualifiedName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal hop representation for building frames from exploit path data.
|
||||
/// </summary>
|
||||
internal sealed record CallChainHop(
|
||||
string Symbol,
|
||||
string? File,
|
||||
int? Line,
|
||||
string? Package,
|
||||
string? Language);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.Triage/StellaOps.Scanner.Triage.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Implement deterministic exploit-path grouping algorithm and triage cluster model wiring for sprint 063 (2026-02-08). |
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Benchmarks;
|
||||
|
||||
public sealed class ReachabilityTierCorpusTests
|
||||
{
|
||||
[Fact]
|
||||
public void Corpus_ShouldContainExpectedToyServices_WithValidLabels()
|
||||
{
|
||||
var corpus = ReachabilityTierCorpus.Load();
|
||||
|
||||
corpus.Services.Select(service => service.Service).Should().Equal(
|
||||
"svc-01-log4shell-java",
|
||||
"svc-02-prototype-pollution-node",
|
||||
"svc-03-pickle-deserialization-python",
|
||||
"svc-04-text-template-go",
|
||||
"svc-05-xmlserializer-dotnet",
|
||||
"svc-06-erb-injection-ruby");
|
||||
|
||||
corpus.Services.Should().OnlyContain(service => service.Cves.Count > 0);
|
||||
corpus.Services.Should().OnlyContain(service => service.SchemaVersion == "v1");
|
||||
|
||||
foreach (var service in corpus.Services)
|
||||
{
|
||||
var serviceDirectory = Path.Combine(corpus.RootPath, service.Service);
|
||||
Directory.Exists(serviceDirectory).Should().BeTrue($"toy service directory '{service.Service}' should exist");
|
||||
|
||||
var entrypointPath = Path.Combine(serviceDirectory, service.Entrypoint);
|
||||
File.Exists(entrypointPath).Should().BeTrue($"entrypoint '{service.Entrypoint}' should exist for '{service.Service}'");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Corpus_ShouldCover_AllR0ToR4Tiers()
|
||||
{
|
||||
var corpus = ReachabilityTierCorpus.Load();
|
||||
|
||||
var tiers = corpus.Services
|
||||
.SelectMany(service => service.Cves)
|
||||
.Select(cve => cve.Tier)
|
||||
.Distinct()
|
||||
.OrderBy(tier => tier)
|
||||
.ToArray();
|
||||
|
||||
tiers.Should().Equal(ReachabilityTier.R0, ReachabilityTier.R1, ReachabilityTier.R2, ReachabilityTier.R3, ReachabilityTier.R4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Corpus_ShouldMapTierLabels_ToReachabilityConfidenceTier()
|
||||
{
|
||||
ReachabilityTier.R0.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Unreachable);
|
||||
ReachabilityTier.R1.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Present);
|
||||
ReachabilityTier.R2.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Present);
|
||||
ReachabilityTier.R3.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Likely);
|
||||
ReachabilityTier.R4.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Confirmed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrecisionRecallHarness_ShouldReportPerfectScores_WhenPredictionsMatchGroundTruth()
|
||||
{
|
||||
var corpus = ReachabilityTierCorpus.Load();
|
||||
var expected = corpus.ToExpectedTierMap();
|
||||
var predicted = new Dictionary<string, ReachabilityTier>(expected, StringComparer.Ordinal);
|
||||
|
||||
var metrics = ReachabilityTierMetricHarness.Compute(expected, predicted);
|
||||
|
||||
metrics.Values.Should().OnlyContain(metric =>
|
||||
metric.TruePositives >= 0 &&
|
||||
metric.FalsePositives >= 0 &&
|
||||
metric.FalseNegatives >= 0 &&
|
||||
metric.Precision == 1.0 &&
|
||||
metric.Recall == 1.0 &&
|
||||
metric.F1 == 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrecisionRecallHarness_ShouldComputePerTierMetrics_Deterministically()
|
||||
{
|
||||
var corpus = ReachabilityTierCorpus.Load();
|
||||
var expected = corpus.ToExpectedTierMap();
|
||||
|
||||
var predicted = new Dictionary<string, ReachabilityTier>(StringComparer.Ordinal)
|
||||
{
|
||||
["CVE-2021-44228"] = ReachabilityTier.R4,
|
||||
["CVE-2022-24999"] = ReachabilityTier.R1,
|
||||
["CVE-2011-2526"] = ReachabilityTier.R3,
|
||||
["CVE-2023-24538"] = ReachabilityTier.R1,
|
||||
["CVE-2021-26701"] = ReachabilityTier.R0,
|
||||
["CVE-2021-41819"] = ReachabilityTier.R2
|
||||
};
|
||||
|
||||
var firstRun = ReachabilityTierMetricHarness.Compute(expected, predicted);
|
||||
var secondRun = ReachabilityTierMetricHarness.Compute(expected, predicted);
|
||||
|
||||
secondRun.Should().Equal(firstRun);
|
||||
|
||||
firstRun[ReachabilityTier.R4].Precision.Should().Be(1.0);
|
||||
firstRun[ReachabilityTier.R4].Recall.Should().Be(0.5);
|
||||
firstRun[ReachabilityTier.R4].F1.Should().BeApproximately(0.6667, 0.0001);
|
||||
|
||||
firstRun[ReachabilityTier.R2].Precision.Should().Be(0.0);
|
||||
firstRun[ReachabilityTier.R2].Recall.Should().Be(0.0);
|
||||
firstRun[ReachabilityTier.R2].F1.Should().Be(0.0);
|
||||
|
||||
firstRun[ReachabilityTier.R1].Precision.Should().Be(0.5);
|
||||
firstRun[ReachabilityTier.R1].Recall.Should().Be(1.0);
|
||||
firstRun[ReachabilityTier.R1].F1.Should().BeApproximately(0.6667, 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ReachabilityTierCorpus(string RootPath, IReadOnlyList<ToyServiceLabel> Services)
|
||||
{
|
||||
public static ReachabilityTierCorpus Load()
|
||||
{
|
||||
var root = ResolveCorpusRoot();
|
||||
var serviceDirectories = Directory
|
||||
.EnumerateDirectories(root, "svc-*", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var services = serviceDirectories
|
||||
.Select(directory => ToyServiceLabelParser.Parse(Path.Combine(directory, "labels.yaml")))
|
||||
.OrderBy(service => service.Service, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new ReachabilityTierCorpus(root, services);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, ReachabilityTier> ToExpectedTierMap()
|
||||
{
|
||||
var map = new SortedDictionary<string, ReachabilityTier>(StringComparer.Ordinal);
|
||||
foreach (var cve in Services.SelectMany(service => service.Cves))
|
||||
{
|
||||
map[cve.Id] = cve.Tier;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string ResolveCorpusRoot()
|
||||
{
|
||||
var outputDatasetPath = Path.Combine(AppContext.BaseDirectory, "Datasets", "toys");
|
||||
if (Directory.Exists(outputDatasetPath))
|
||||
{
|
||||
return outputDatasetPath;
|
||||
}
|
||||
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var repoDatasetPath = Path.Combine(current.FullName, "src", "Scanner", "__Tests", "__Datasets", "toys");
|
||||
if (Directory.Exists(repoDatasetPath))
|
||||
{
|
||||
return repoDatasetPath;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the toy reachability dataset directory.");
|
||||
}
|
||||
}
|
||||
|
||||
internal enum ReachabilityTier
|
||||
{
|
||||
R0 = 0,
|
||||
R1 = 1,
|
||||
R2 = 2,
|
||||
R3 = 3,
|
||||
R4 = 4
|
||||
}
|
||||
|
||||
internal static class ReachabilityTierExtensions
|
||||
{
|
||||
public static ReachabilityConfidenceTier ToConfidenceTier(this ReachabilityTier tier) =>
|
||||
tier switch
|
||||
{
|
||||
ReachabilityTier.R0 => ReachabilityConfidenceTier.Unreachable,
|
||||
ReachabilityTier.R1 => ReachabilityConfidenceTier.Present,
|
||||
ReachabilityTier.R2 => ReachabilityConfidenceTier.Present,
|
||||
ReachabilityTier.R3 => ReachabilityConfidenceTier.Likely,
|
||||
ReachabilityTier.R4 => ReachabilityConfidenceTier.Confirmed,
|
||||
_ => ReachabilityConfidenceTier.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record ToyServiceLabel(
|
||||
string SchemaVersion,
|
||||
string Service,
|
||||
string Language,
|
||||
string Entrypoint,
|
||||
IReadOnlyList<ToyCveLabel> Cves);
|
||||
|
||||
internal sealed record ToyCveLabel(
|
||||
string Id,
|
||||
string Package,
|
||||
ReachabilityTier Tier,
|
||||
string Rationale);
|
||||
|
||||
internal static class ToyServiceLabelParser
|
||||
{
|
||||
public static ToyServiceLabel Parse(string labelsPath)
|
||||
{
|
||||
if (!File.Exists(labelsPath))
|
||||
{
|
||||
throw new FileNotFoundException("labels.yaml is required for every toy service.", labelsPath);
|
||||
}
|
||||
|
||||
string? schemaVersion = null;
|
||||
string? service = null;
|
||||
string? language = null;
|
||||
string? entrypoint = null;
|
||||
var cves = new List<ToyCveLabel>();
|
||||
CveBuilder? current = null;
|
||||
|
||||
foreach (var rawLine in File.ReadLines(labelsPath))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("- id:", StringComparison.Ordinal))
|
||||
{
|
||||
if (current is not null)
|
||||
{
|
||||
cves.Add(current.Build(labelsPath));
|
||||
}
|
||||
|
||||
current = new CveBuilder { Id = ValueAfterColon(line) };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("schema_version:", StringComparison.Ordinal))
|
||||
{
|
||||
schemaVersion = ValueAfterColon(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("service:", StringComparison.Ordinal))
|
||||
{
|
||||
service = ValueAfterColon(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("language:", StringComparison.Ordinal))
|
||||
{
|
||||
language = ValueAfterColon(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("entrypoint:", StringComparison.Ordinal))
|
||||
{
|
||||
entrypoint = ValueAfterColon(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("package:", StringComparison.Ordinal))
|
||||
{
|
||||
current.Package = ValueAfterColon(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("tier:", StringComparison.Ordinal))
|
||||
{
|
||||
current.Tier = ParseTier(ValueAfterColon(line), labelsPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("rationale:", StringComparison.Ordinal))
|
||||
{
|
||||
current.Rationale = ValueAfterColon(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (current is not null)
|
||||
{
|
||||
cves.Add(current.Build(labelsPath));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(schemaVersion) ||
|
||||
string.IsNullOrWhiteSpace(service) ||
|
||||
string.IsNullOrWhiteSpace(language) ||
|
||||
string.IsNullOrWhiteSpace(entrypoint))
|
||||
{
|
||||
throw new InvalidDataException($"labels.yaml is missing required top-level fields: {labelsPath}");
|
||||
}
|
||||
|
||||
if (cves.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException($"labels.yaml must include at least one CVE label: {labelsPath}");
|
||||
}
|
||||
|
||||
return new ToyServiceLabel(schemaVersion, service, language, entrypoint, cves);
|
||||
}
|
||||
|
||||
private static ReachabilityTier ParseTier(string value, string labelsPath) =>
|
||||
value switch
|
||||
{
|
||||
"R0" => ReachabilityTier.R0,
|
||||
"R1" => ReachabilityTier.R1,
|
||||
"R2" => ReachabilityTier.R2,
|
||||
"R3" => ReachabilityTier.R3,
|
||||
"R4" => ReachabilityTier.R4,
|
||||
_ => throw new InvalidDataException($"Unsupported tier '{value}' in {labelsPath}.")
|
||||
};
|
||||
|
||||
private static string ValueAfterColon(string line)
|
||||
{
|
||||
var separator = line.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separator < 0 || separator == line.Length - 1)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return line[(separator + 1)..].Trim();
|
||||
}
|
||||
|
||||
private sealed class CveBuilder
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Package { get; set; }
|
||||
public ReachabilityTier? Tier { get; set; }
|
||||
public string? Rationale { get; set; }
|
||||
|
||||
public ToyCveLabel Build(string labelsPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Id) ||
|
||||
string.IsNullOrWhiteSpace(Package) ||
|
||||
!Tier.HasValue ||
|
||||
string.IsNullOrWhiteSpace(Rationale))
|
||||
{
|
||||
throw new InvalidDataException($"CVE label entry is missing required fields in {labelsPath}.");
|
||||
}
|
||||
|
||||
return new ToyCveLabel(Id, Package, Tier.Value, Rationale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ReachabilityTierMetricHarness
|
||||
{
|
||||
public static IReadOnlyDictionary<ReachabilityTier, TierMetrics> Compute(
|
||||
IReadOnlyDictionary<string, ReachabilityTier> expected,
|
||||
IReadOnlyDictionary<string, ReachabilityTier> predicted)
|
||||
{
|
||||
var cveIds = expected.Keys
|
||||
.Concat(predicted.Keys)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var results = new SortedDictionary<ReachabilityTier, TierMetrics>();
|
||||
foreach (ReachabilityTier tier in Enum.GetValues<ReachabilityTier>())
|
||||
{
|
||||
var truePositives = 0;
|
||||
var falsePositives = 0;
|
||||
var falseNegatives = 0;
|
||||
|
||||
foreach (var cveId in cveIds)
|
||||
{
|
||||
var expectedTier = expected.TryGetValue(cveId, out var expectedValue) ? expectedValue : (ReachabilityTier?)null;
|
||||
var predictedTier = predicted.TryGetValue(cveId, out var predictedValue) ? predictedValue : (ReachabilityTier?)null;
|
||||
|
||||
if (expectedTier == tier && predictedTier == tier)
|
||||
{
|
||||
truePositives++;
|
||||
}
|
||||
else if (expectedTier != tier && predictedTier == tier)
|
||||
{
|
||||
falsePositives++;
|
||||
}
|
||||
else if (expectedTier == tier && predictedTier != tier)
|
||||
{
|
||||
falseNegatives++;
|
||||
}
|
||||
}
|
||||
|
||||
var precision = truePositives + falsePositives == 0
|
||||
? 1.0
|
||||
: (double)truePositives / (truePositives + falsePositives);
|
||||
var recall = truePositives + falseNegatives == 0
|
||||
? 1.0
|
||||
: (double)truePositives / (truePositives + falseNegatives);
|
||||
var f1 = precision + recall == 0
|
||||
? 0.0
|
||||
: 2 * precision * recall / (precision + recall);
|
||||
|
||||
results[tier] = new TierMetrics(
|
||||
truePositives,
|
||||
falsePositives,
|
||||
falseNegatives,
|
||||
Math.Round(precision, 4),
|
||||
Math.Round(recall, 4),
|
||||
Math.Round(f1, 4));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record TierMetrics(
|
||||
int TruePositives,
|
||||
int FalsePositives,
|
||||
int FalseNegatives,
|
||||
double Precision,
|
||||
double Recall,
|
||||
double F1);
|
||||
@@ -24,4 +24,10 @@
|
||||
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Decompiler/StellaOps.BinaryIndex.Decompiler.csproj" />
|
||||
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Ghidra/StellaOps.BinaryIndex.Ghidra.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\__Datasets\toys\**\*"
|
||||
Link="Datasets\toys\%(RecursiveDir)%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/StellaOps.Scanner.Reachability.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-059-REACHCORPUS-001 | DONE | Built deterministic toy-service reachability corpus (`labels.yaml`) and per-tier precision/recall harness for sprint 059 (2026-02-08). |
|
||||
|
||||
@@ -1,257 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Tests;
|
||||
|
||||
public sealed class ExploitPathGroupingServiceTests
|
||||
{
|
||||
private readonly Mock<IReachabilityQueryService> _reachabilityMock;
|
||||
private readonly Mock<IVexDecisionService> _vexServiceMock;
|
||||
private readonly Mock<IExceptionEvaluator> _exceptionEvaluatorMock;
|
||||
private readonly Mock<ILogger<ExploitPathGroupingService>> _loggerMock;
|
||||
private readonly ExploitPathGroupingService _service;
|
||||
|
||||
public ExploitPathGroupingServiceTests()
|
||||
{
|
||||
_reachabilityMock = new Mock<IReachabilityQueryService>();
|
||||
_vexServiceMock = new Mock<IVexDecisionService>();
|
||||
_exceptionEvaluatorMock = new Mock<IExceptionEvaluator>();
|
||||
_loggerMock = new Mock<ILogger<ExploitPathGroupingService>>();
|
||||
|
||||
_service = new ExploitPathGroupingService(
|
||||
_reachabilityMock.Object,
|
||||
_vexServiceMock.Object,
|
||||
_exceptionEvaluatorMock.Object,
|
||||
_loggerMock.Object);
|
||||
}
|
||||
private static readonly DateTimeOffset BaseTime = new(2026, 2, 8, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GroupFindingsAsync_WhenNoReachGraph_UsesFallback()
|
||||
[Fact]
|
||||
public async Task GroupFindingsAsync_WithCommonCallChainPrefix_ClustersFindingsDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var artifactDigest = "sha256:test";
|
||||
var findings = CreateTestFindings();
|
||||
_reachabilityMock.Setup(x => x.GetReachGraphAsync(artifactDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ReachabilityGraph?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GroupFindingsAsync(artifactDigest, findings);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeEmpty();
|
||||
result.Should().AllSatisfy(p =>
|
||||
var service = new ExploitPathGroupingService(NullLogger<ExploitPathGroupingService>.Instance);
|
||||
var findings = new[]
|
||||
{
|
||||
p.Reachability.Should().Be(ReachabilityStatus.Unknown);
|
||||
p.Symbol.FullyQualifiedName.Should().Be("unknown");
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GroupFindingsAsync_GroupsByPackageSymbolEntry()
|
||||
{
|
||||
// Arrange
|
||||
var artifactDigest = "sha256:test";
|
||||
var findings = CreateTestFindings();
|
||||
var graphMock = new Mock<ReachabilityGraph>();
|
||||
|
||||
_reachabilityMock.Setup(x => x.GetReachGraphAsync(artifactDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(graphMock.Object);
|
||||
|
||||
graphMock.Setup(x => x.GetSymbolsForPackage(It.IsAny<string>()))
|
||||
.Returns(new List<VulnerableSymbol>
|
||||
{
|
||||
new VulnerableSymbol("com.example.Foo.bar", "Foo.java", 42, "java")
|
||||
});
|
||||
|
||||
graphMock.Setup(x => x.GetEntryPointsTo(It.IsAny<string>()))
|
||||
.Returns(new List<EntryPoint>
|
||||
{
|
||||
new EntryPoint("POST /api/users", "http", "/api/users")
|
||||
});
|
||||
|
||||
graphMock.Setup(x => x.GetPathsTo(It.IsAny<string>()))
|
||||
.Returns(new List<ReachPath>
|
||||
{
|
||||
new ReachPath("POST /api/users", "com.example.Foo.bar", false, 0.8m)
|
||||
});
|
||||
|
||||
_vexServiceMock.Setup(x => x.GetStatusForPathAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ImmutableArray<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new VexStatusResult(false, VexStatus.Unknown, null, 0m));
|
||||
|
||||
_exceptionEvaluatorMock.Setup(x => x.GetActiveExceptionsForPathAsync(
|
||||
It.IsAny<string>(), It.IsAny<ImmutableArray<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ActiveException>());
|
||||
|
||||
// Act
|
||||
var result = await _service.GroupFindingsAsync(artifactDigest, findings);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeEmpty();
|
||||
result.Should().AllSatisfy(p =>
|
||||
{
|
||||
p.PathId.Should().StartWith("path:");
|
||||
p.Package.Purl.Should().NotBeNullOrEmpty();
|
||||
p.Symbol.FullyQualifiedName.Should().NotBeNullOrEmpty();
|
||||
p.Evidence.Items.Should().NotBeEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GeneratePathId_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:test";
|
||||
var purl = "pkg:maven/com.example/lib@1.0.0";
|
||||
var symbol = "com.example.Lib.method";
|
||||
var entry = "POST /api";
|
||||
|
||||
// Act
|
||||
var id1 = ExploitPathGroupingService.GeneratePathId(digest, purl, symbol, entry);
|
||||
var id2 = ExploitPathGroupingService.GeneratePathId(digest, purl, symbol, entry);
|
||||
|
||||
// Assert
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().StartWith("path:");
|
||||
id1.Length.Should().Be(21); // "path:" + 16 hex chars
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Finding> CreateTestFindings()
|
||||
{
|
||||
return new List<Finding>
|
||||
{
|
||||
new Finding(
|
||||
"finding-001",
|
||||
"pkg:maven/com.example/lib@1.0.0",
|
||||
"lib",
|
||||
"1.0.0",
|
||||
new List<string> { "CVE-2024-1234" },
|
||||
7.5m,
|
||||
0.3m,
|
||||
CreateFinding(
|
||||
"finding-a",
|
||||
Severity.Critical,
|
||||
cvss: 9.8m,
|
||||
callChain: ["http:POST:/orders", "OrdersController.Post", "OrderService.Execute", "SqlSink.Write"]),
|
||||
CreateFinding(
|
||||
"finding-b",
|
||||
Severity.High,
|
||||
"sha256:test",
|
||||
DateTimeOffset.UtcNow.AddDays(-7))
|
||||
cvss: 8.1m,
|
||||
callChain: ["http:POST:/orders", "OrdersController.Post", "OrderService.Execute", "KafkaSink.Publish"]),
|
||||
CreateFinding(
|
||||
"finding-c",
|
||||
Severity.Low,
|
||||
cvss: 3.2m,
|
||||
callChain: ["http:GET:/health", "HealthController.Get", "HealthService.Execute", "LogSink.Write"])
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Stub types for unimplemented services
|
||||
public interface IReachabilityQueryService
|
||||
{
|
||||
Task<ReachabilityGraph?> GetReachGraphAsync(string artifactDigest, CancellationToken cancellationToken);
|
||||
}
|
||||
var grouped = await service.GroupFindingsAsync("sha256:test", findings, similarityThreshold: 0.75m);
|
||||
|
||||
public interface IExceptionEvaluator
|
||||
{
|
||||
Task<IReadOnlyList<ActiveException>> GetActiveExceptionsForPathAsync(string pathId, ImmutableArray<string> vulnIds, CancellationToken cancellationToken);
|
||||
}
|
||||
grouped.Should().HaveCount(2);
|
||||
grouped.Should().OnlyContain(path => path.FindingIds.Length > 0);
|
||||
grouped.Should().OnlyContain(path => path.PathId.StartsWith("path:", StringComparison.Ordinal));
|
||||
|
||||
public interface IVexDecisionService
|
||||
{
|
||||
Task<VexStatusResult> GetStatusForPathAsync(string vulnId, string purl, ImmutableArray<string> path, CancellationToken ct);
|
||||
}
|
||||
|
||||
public record VexStatusResult(bool HasStatus, VexStatus Status, string? Justification, decimal Confidence);
|
||||
|
||||
public enum VexStatus { Unknown, Affected, NotAffected, UnderInvestigation }
|
||||
|
||||
public class ExploitPathGroupingService
|
||||
{
|
||||
private readonly IReachabilityQueryService _reachability;
|
||||
|
||||
public ExploitPathGroupingService(IReachabilityQueryService r, IVexDecisionService v, IExceptionEvaluator e, ILogger<ExploitPathGroupingService> l)
|
||||
{
|
||||
_reachability = r;
|
||||
var mergedCluster = grouped.Single(path => path.FindingIds.Length == 2);
|
||||
mergedCluster.FindingIds.Should().Equal("finding-a", "finding-b");
|
||||
mergedCluster.RiskScore.CriticalCount.Should().Be(1);
|
||||
mergedCluster.RiskScore.HighCount.Should().Be(1);
|
||||
}
|
||||
|
||||
public async Task<List<ExploitPath>> GroupFindingsAsync(string digest, IReadOnlyList<Finding> findings)
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GroupFindingsAsync_WithIdenticalInput_IsStableAcrossRuns()
|
||||
{
|
||||
var graph = await _reachability.GetReachGraphAsync(digest, CancellationToken.None);
|
||||
var result = new List<ExploitPath>();
|
||||
|
||||
foreach (var finding in findings)
|
||||
var service = new ExploitPathGroupingService(NullLogger<ExploitPathGroupingService>.Instance);
|
||||
var findings = new[]
|
||||
{
|
||||
if (graph == null)
|
||||
{
|
||||
// Fallback when no reachability graph exists
|
||||
result.Add(new ExploitPath(
|
||||
GeneratePathId(digest, finding.Purl, "unknown", "unknown"),
|
||||
new PackageInfo(finding.Purl),
|
||||
new SymbolInfo("unknown"),
|
||||
ReachabilityStatus.Unknown,
|
||||
new EvidenceCollection(new List<object> { finding })));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use reachability graph to group by symbols
|
||||
var symbols = graph.GetSymbolsForPackage(finding.Purl);
|
||||
foreach (var symbol in symbols)
|
||||
{
|
||||
var entries = graph.GetEntryPointsTo(symbol.Name);
|
||||
var entry = entries.FirstOrDefault()?.Name ?? "unknown";
|
||||
result.Add(new ExploitPath(
|
||||
GeneratePathId(digest, finding.Purl, symbol.Name, entry),
|
||||
new PackageInfo(finding.Purl),
|
||||
new SymbolInfo(symbol.Name),
|
||||
ReachabilityStatus.Reachable,
|
||||
new EvidenceCollection(new List<object> { finding, symbol })));
|
||||
}
|
||||
}
|
||||
}
|
||||
CreateFinding("finding-01", Severity.High, callChain: ["entry:a", "mid:a", "sink:a"]),
|
||||
CreateFinding("finding-02", Severity.Medium, callChain: ["entry:a", "mid:a", "sink:b"]),
|
||||
CreateFinding("finding-03", Severity.Low, callChain: ["entry:b", "mid:b", "sink:c"])
|
||||
};
|
||||
|
||||
return result;
|
||||
var run1 = await service.GroupFindingsAsync("sha256:test", findings, similarityThreshold: 0.67m);
|
||||
var run2 = await service.GroupFindingsAsync("sha256:test", findings, similarityThreshold: 0.67m);
|
||||
|
||||
run1.Select(static p => p.PathId).Should().Equal(run2.Select(static p => p.PathId));
|
||||
run1.Select(static p => string.Join(',', p.FindingIds)).Should().Equal(run2.Select(static p => string.Join(',', p.FindingIds)));
|
||||
run1.Select(static p => p.PriorityScore).Should().Equal(run2.Select(static p => p.PriorityScore));
|
||||
}
|
||||
|
||||
public static string GeneratePathId(string digest, string purl, string symbol, string entry) => "path:0123456789abcdef";
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GroupFindingsAsync_ComputesPriorityAndReachability()
|
||||
{
|
||||
var service = new ExploitPathGroupingService(NullLogger<ExploitPathGroupingService>.Instance);
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding(
|
||||
"reachable-critical",
|
||||
Severity.Critical,
|
||||
cvss: 9.4m,
|
||||
reachability: ReachabilityStatus.RuntimeConfirmed,
|
||||
reachabilityConfidence: 0.95m,
|
||||
callChain: ["entry:r", "sink:r"]),
|
||||
CreateFinding(
|
||||
"unreachable-low",
|
||||
Severity.Low,
|
||||
cvss: 2.0m,
|
||||
reachability: ReachabilityStatus.Unreachable,
|
||||
reachabilityConfidence: 0.25m,
|
||||
callChain: ["entry:u", "sink:u"])
|
||||
};
|
||||
|
||||
var grouped = await service.GroupFindingsAsync("sha256:test", findings, similarityThreshold: 0.90m);
|
||||
|
||||
grouped.Should().HaveCount(2);
|
||||
var reachable = grouped.Single(path => path.FindingIds.Contains("reachable-critical"));
|
||||
var unreachable = grouped.Single(path => path.FindingIds.Contains("unreachable-low"));
|
||||
|
||||
reachable.Reachability.Should().Be(ReachabilityStatus.RuntimeConfirmed);
|
||||
reachable.PriorityScore.Should().BeGreaterThan(unreachable.PriorityScore);
|
||||
reachable.Evidence.Confidence.Should().BeGreaterThan(unreachable.Evidence.Confidence);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GeneratePathId_WithSameInputs_IsDeterministic()
|
||||
{
|
||||
var first = ExploitPathGroupingService.GeneratePathId(
|
||||
"sha256:test",
|
||||
"pkg:npm/acme/widget@1.2.3",
|
||||
"WidgetService.Execute",
|
||||
"POST /api/widgets");
|
||||
var second = ExploitPathGroupingService.GeneratePathId(
|
||||
"sha256:test",
|
||||
"pkg:npm/acme/widget@1.2.3",
|
||||
"WidgetService.Execute",
|
||||
"POST /api/widgets");
|
||||
|
||||
first.Should().Be(second);
|
||||
first.Should().StartWith("path:");
|
||||
first.Length.Should().Be(21);
|
||||
}
|
||||
|
||||
private static Finding CreateFinding(
|
||||
string findingId,
|
||||
Severity severity,
|
||||
decimal cvss = 7.0m,
|
||||
IReadOnlyList<string>? callChain = null,
|
||||
ReachabilityStatus? reachability = null,
|
||||
decimal? reachabilityConfidence = null)
|
||||
=> new(
|
||||
findingId,
|
||||
"pkg:npm/acme/widget@1.2.3",
|
||||
"widget",
|
||||
"1.2.3",
|
||||
["CVE-2026-1234"],
|
||||
cvss,
|
||||
0.42m,
|
||||
severity,
|
||||
"sha256:test",
|
||||
BaseTime,
|
||||
callChain,
|
||||
callChain is { Count: > 0 } ? callChain[0] : "entrypoint:unknown",
|
||||
callChain is { Count: > 0 } ? callChain[^1] : "symbol:unknown",
|
||||
reachability,
|
||||
reachabilityConfidence);
|
||||
}
|
||||
|
||||
public record ExploitPath(
|
||||
string PathId,
|
||||
PackageInfo Package,
|
||||
SymbolInfo Symbol,
|
||||
ReachabilityStatus Reachability,
|
||||
EvidenceCollection Evidence);
|
||||
|
||||
public record PackageInfo(string Purl);
|
||||
public record SymbolInfo(string FullyQualifiedName);
|
||||
public record EvidenceCollection(List<object> Items);
|
||||
public enum ReachabilityStatus { Unknown, Reachable, NotReachable }
|
||||
|
||||
public record Finding(
|
||||
string Id,
|
||||
string Purl,
|
||||
string Name,
|
||||
string Version,
|
||||
List<string> Vulnerabilities,
|
||||
decimal Score,
|
||||
decimal Confidence,
|
||||
Severity Severity,
|
||||
string Digest,
|
||||
DateTimeOffset DiscoveredAt);
|
||||
|
||||
public enum Severity { Low, Medium, High, Critical }
|
||||
|
||||
public abstract class ReachabilityGraph
|
||||
{
|
||||
public abstract List<VulnerableSymbol> GetSymbolsForPackage(string purl);
|
||||
public abstract List<EntryPoint> GetEntryPointsTo(string symbol);
|
||||
public abstract List<ReachPath> GetPathsTo(string symbol);
|
||||
}
|
||||
|
||||
public record VulnerableSymbol(string Name, string File, int Line, string Language);
|
||||
public record EntryPoint(string Name, string Type, string Path);
|
||||
public record ReachPath(string Entry, string Target, bool IsAsync, decimal Confidence);
|
||||
|
||||
public record ActiveException(string Id, string Reason);
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic tests for StackTraceExploitPathView models and service.
|
||||
/// No network calls — all assertions use in-memory fixtures.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class StackTraceExploitPathViewServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime =
|
||||
new(2026, 2, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly StackTraceExploitPathViewService _service = new(
|
||||
NullLogger<StackTraceExploitPathViewService>.Instance);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Model: StackTraceExploitPathView
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void View_Depth_EqualsFrameCount()
|
||||
{
|
||||
var view = CreateMinimalView(frameCount: 5);
|
||||
view.Depth.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_CollapsedByDefault_TrueForDeepPaths()
|
||||
{
|
||||
var view = CreateMinimalView(frameCount: 4);
|
||||
view.CollapsedByDefault.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_CollapsedByDefault_FalseForShallowPaths()
|
||||
{
|
||||
var view = CreateMinimalView(frameCount: 3);
|
||||
view.CollapsedByDefault.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_CollapsedByDefault_FalseForTwoFrames()
|
||||
{
|
||||
var view = CreateMinimalView(frameCount: 2);
|
||||
view.CollapsedByDefault.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_SeverityLabel_Critical()
|
||||
{
|
||||
var view = CreateMinimalView() with { PriorityScore = 9.5m };
|
||||
view.SeverityLabel.Should().Be("Critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_SeverityLabel_High()
|
||||
{
|
||||
var view = CreateMinimalView() with { PriorityScore = 8.0m };
|
||||
view.SeverityLabel.Should().Be("High");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_SeverityLabel_Medium()
|
||||
{
|
||||
var view = CreateMinimalView() with { PriorityScore = 5.0m };
|
||||
view.SeverityLabel.Should().Be("Medium");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_SeverityLabel_Low()
|
||||
{
|
||||
var view = CreateMinimalView() with { PriorityScore = 2.0m };
|
||||
view.SeverityLabel.Should().Be("Low");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_SeverityLabel_Info()
|
||||
{
|
||||
var view = CreateMinimalView() with { PriorityScore = 0.5m };
|
||||
view.SeverityLabel.Should().Be("Info");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Model: StackTraceFrame
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Frame_HasSource_TrueWhenFileAndLinePresent()
|
||||
{
|
||||
var frame = new StackTraceFrame
|
||||
{
|
||||
Index = 0,
|
||||
Symbol = "MyMethod",
|
||||
Role = FrameRole.Entrypoint,
|
||||
File = "src/MyClass.cs",
|
||||
Line = 42,
|
||||
};
|
||||
|
||||
frame.HasSource.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Frame_HasSource_FalseWhenFileIsNull()
|
||||
{
|
||||
var frame = new StackTraceFrame
|
||||
{
|
||||
Index = 0,
|
||||
Symbol = "MyMethod",
|
||||
Role = FrameRole.Entrypoint,
|
||||
File = null,
|
||||
Line = 42,
|
||||
};
|
||||
|
||||
frame.HasSource.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Frame_HasSource_FalseWhenLineIsNull()
|
||||
{
|
||||
var frame = new StackTraceFrame
|
||||
{
|
||||
Index = 0,
|
||||
Symbol = "MyMethod",
|
||||
Role = FrameRole.Entrypoint,
|
||||
File = "src/MyClass.cs",
|
||||
Line = null,
|
||||
};
|
||||
|
||||
frame.HasSource.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Frame_DisplayLabel_WithSource()
|
||||
{
|
||||
var frame = new StackTraceFrame
|
||||
{
|
||||
Index = 0,
|
||||
Symbol = "OrderService.Execute",
|
||||
Role = FrameRole.Intermediate,
|
||||
File = "src/OrderService.cs",
|
||||
Line = 55,
|
||||
};
|
||||
|
||||
frame.DisplayLabel.Should().Be("OrderService.Execute (src/OrderService.cs:55)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Frame_DisplayLabel_WithoutSource()
|
||||
{
|
||||
var frame = new StackTraceFrame
|
||||
{
|
||||
Index = 0,
|
||||
Symbol = "OrderService.Execute",
|
||||
Role = FrameRole.Intermediate,
|
||||
};
|
||||
|
||||
frame.DisplayLabel.Should().Be("OrderService.Execute");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Service: BuildView
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildView_ThrowsOnNullRequest()
|
||||
{
|
||||
var act = () => _service.BuildView(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildView_MinimalPath_HasEntrypointAndSinkFrames()
|
||||
{
|
||||
var request = new StackTraceViewRequest { Path = CreateExploitPath() };
|
||||
var view = _service.BuildView(request);
|
||||
|
||||
view.PathId.Should().Be("path:test-001");
|
||||
view.Frames.Should().HaveCountGreaterOrEqualTo(2);
|
||||
view.Frames[0].Role.Should().Be(FrameRole.Entrypoint);
|
||||
view.Frames[^1].Role.Should().Be(FrameRole.Sink);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildView_SetsTitle_WithCveAndSymbolNames()
|
||||
{
|
||||
var request = new StackTraceViewRequest { Path = CreateExploitPath() };
|
||||
var view = _service.BuildView(request);
|
||||
|
||||
view.Title.Should().Contain("CVE-2024-12345");
|
||||
view.Title.Should().Contain("SqlClient.Execute");
|
||||
view.Title.Should().Contain("POST /api/orders");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildView_MultipleCves_ShowsCountInTitle()
|
||||
{
|
||||
var path = CreateExploitPath() with
|
||||
{
|
||||
CveIds = ["CVE-2024-11111", "CVE-2024-22222", "CVE-2024-33333"],
|
||||
};
|
||||
var request = new StackTraceViewRequest { Path = path };
|
||||
var view = _service.BuildView(request);
|
||||
|
||||
view.Title.Should().Contain("(+2)");
|
||||
view.CveIds.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildView_WithSourceMappings_AttachesSnippets()
|
||||
{
|
||||
var snippet = new SourceSnippet
|
||||
{
|
||||
Code = "public void Execute() { /* vulnerable */ }",
|
||||
StartLine = 50,
|
||||
EndLine = 55,
|
||||
HighlightLine = 52,
|
||||
Language = "csharp",
|
||||
};
|
||||
|
||||
var path = CreateExploitPath();
|
||||
var sourceKey = $"{path.Symbol.SourceFile}:{path.Symbol.LineNumber}";
|
||||
var mappings = ImmutableDictionary.CreateRange(
|
||||
[KeyValuePair.Create(sourceKey, snippet)]);
|
||||
|
||||
var request = new StackTraceViewRequest
|
||||
{
|
||||
Path = path,
|
||||
SourceMappings = mappings,
|
||||
};
|
||||
|
||||
var view = _service.BuildView(request);
|
||||
var sinkFrame = view.Frames[^1];
|
||||
|
||||
sinkFrame.SourceSnippet.Should().NotBeNull();
|
||||
sinkFrame.SourceSnippet!.Code.Should().Contain("Execute");
|
||||
sinkFrame.SourceSnippet.Language.Should().Be("csharp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildView_WithGateLabels_SetsGatedRole()
|
||||
{
|
||||
var gateLabels = ImmutableDictionary.CreateRange(
|
||||
[KeyValuePair.Create(1, "AuthZ check")]);
|
||||
|
||||
var path = CreateExploitPathWithHighConfidence();
|
||||
var request = new StackTraceViewRequest
|
||||
{
|
||||
Path = path,
|
||||
GateLabels = gateLabels,
|
||||
};
|
||||
|
||||
var view = _service.BuildView(request);
|
||||
|
||||
// There should be at least one intermediate frame with a gate
|
||||
var gatedFrames = view.Frames.Where(f => f.Role == FrameRole.GatedIntermediate).ToList();
|
||||
if (view.Frames.Length > 2)
|
||||
{
|
||||
gatedFrames.Should().NotBeEmpty();
|
||||
gatedFrames[0].GateLabel.Should().Be("AuthZ check");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildView_PreservesReachabilityStatus()
|
||||
{
|
||||
var path = CreateExploitPath() with
|
||||
{
|
||||
Reachability = ReachabilityStatus.RuntimeConfirmed,
|
||||
};
|
||||
var request = new StackTraceViewRequest { Path = path };
|
||||
var view = _service.BuildView(request);
|
||||
|
||||
view.Reachability.Should().Be(ReachabilityStatus.RuntimeConfirmed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildView_PreservesPriorityScore()
|
||||
{
|
||||
var path = CreateExploitPath() with { PriorityScore = 8.5m };
|
||||
var request = new StackTraceViewRequest { Path = path };
|
||||
var view = _service.BuildView(request);
|
||||
|
||||
view.PriorityScore.Should().Be(8.5m);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Service: BuildViews (batch)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildViews_ThrowsOnNull()
|
||||
{
|
||||
var act = () => _service.BuildViews(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildViews_EmptyList_ReturnsEmpty()
|
||||
{
|
||||
var result = _service.BuildViews([]);
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildViews_OrdersByPriorityDescending()
|
||||
{
|
||||
var requests = new[]
|
||||
{
|
||||
new StackTraceViewRequest
|
||||
{
|
||||
Path = CreateExploitPath("path:low") with { PriorityScore = 2.0m },
|
||||
},
|
||||
new StackTraceViewRequest
|
||||
{
|
||||
Path = CreateExploitPath("path:high") with { PriorityScore = 9.0m },
|
||||
},
|
||||
new StackTraceViewRequest
|
||||
{
|
||||
Path = CreateExploitPath("path:mid") with { PriorityScore = 5.0m },
|
||||
},
|
||||
};
|
||||
|
||||
var views = _service.BuildViews(requests);
|
||||
|
||||
views.Should().HaveCount(3);
|
||||
views[0].PathId.Should().Be("path:high");
|
||||
views[1].PathId.Should().Be("path:mid");
|
||||
views[2].PathId.Should().Be("path:low");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildViews_SamePriority_OrdersByPathIdForDeterminism()
|
||||
{
|
||||
var requests = new[]
|
||||
{
|
||||
new StackTraceViewRequest
|
||||
{
|
||||
Path = CreateExploitPath("path:zzz") with { PriorityScore = 5.0m },
|
||||
},
|
||||
new StackTraceViewRequest
|
||||
{
|
||||
Path = CreateExploitPath("path:aaa") with { PriorityScore = 5.0m },
|
||||
},
|
||||
};
|
||||
|
||||
var views = _service.BuildViews(requests);
|
||||
|
||||
views[0].PathId.Should().Be("path:aaa");
|
||||
views[1].PathId.Should().Be("path:zzz");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal: DetermineRole
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DetermineRole_FirstFrame_IsEntrypoint()
|
||||
{
|
||||
StackTraceExploitPathViewService.DetermineRole(0, 5, false)
|
||||
.Should().Be(FrameRole.Entrypoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineRole_LastFrame_IsSink()
|
||||
{
|
||||
StackTraceExploitPathViewService.DetermineRole(4, 5, false)
|
||||
.Should().Be(FrameRole.Sink);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineRole_MiddleFrame_IsIntermediate()
|
||||
{
|
||||
StackTraceExploitPathViewService.DetermineRole(2, 5, false)
|
||||
.Should().Be(FrameRole.Intermediate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineRole_MiddleFrameWithGate_IsGatedIntermediate()
|
||||
{
|
||||
StackTraceExploitPathViewService.DetermineRole(2, 5, true)
|
||||
.Should().Be(FrameRole.GatedIntermediate);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal: BuildTitle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildTitle_SingleCve_NoPlusCount()
|
||||
{
|
||||
var path = CreateExploitPath();
|
||||
var title = StackTraceExploitPathViewService.BuildTitle(path);
|
||||
|
||||
title.Should().Be("CVE-2024-12345 via POST /api/orders → SqlClient.Execute");
|
||||
title.Should().NotContain("(+");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTitle_NoCves_ShowsUnknown()
|
||||
{
|
||||
var path = CreateExploitPath() with { CveIds = [] };
|
||||
var title = StackTraceExploitPathViewService.BuildTitle(path);
|
||||
|
||||
title.Should().Contain("Unknown CVE");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal: ExtractCallChain
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ExtractCallChain_AlwaysHasEntrypointAndSink()
|
||||
{
|
||||
var path = CreateExploitPath();
|
||||
var chain = StackTraceExploitPathViewService.ExtractCallChain(path);
|
||||
|
||||
chain.Should().HaveCountGreaterOrEqualTo(2);
|
||||
chain[0].Symbol.Should().Be("POST /api/orders");
|
||||
chain[^1].Symbol.Should().Be("SqlClient.Execute");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractCallChain_SinkHasSourceInfo()
|
||||
{
|
||||
var path = CreateExploitPath();
|
||||
var chain = StackTraceExploitPathViewService.ExtractCallChain(path);
|
||||
var sink = chain[^1];
|
||||
|
||||
sink.File.Should().Be("src/Data/SqlClient.cs");
|
||||
sink.Line.Should().Be(42);
|
||||
sink.Package.Should().Be("System.Data.SqlClient");
|
||||
sink.Language.Should().Be("csharp");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Determinism
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildView_IsDeterministic_IdenticalInputProducesIdenticalOutput()
|
||||
{
|
||||
var request = new StackTraceViewRequest { Path = CreateExploitPath() };
|
||||
|
||||
var view1 = _service.BuildView(request);
|
||||
var view2 = _service.BuildView(request);
|
||||
|
||||
view1.PathId.Should().Be(view2.PathId);
|
||||
view1.Title.Should().Be(view2.Title);
|
||||
view1.Depth.Should().Be(view2.Depth);
|
||||
view1.Frames.Length.Should().Be(view2.Frames.Length);
|
||||
|
||||
for (var i = 0; i < view1.Frames.Length; i++)
|
||||
{
|
||||
view1.Frames[i].Symbol.Should().Be(view2.Frames[i].Symbol);
|
||||
view1.Frames[i].Role.Should().Be(view2.Frames[i].Role);
|
||||
view1.Frames[i].Index.Should().Be(view2.Frames[i].Index);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Model: SourceSnippet
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SourceSnippet_AllFieldsRoundtrip()
|
||||
{
|
||||
var snippet = new SourceSnippet
|
||||
{
|
||||
Code = "var x = db.Execute(query);",
|
||||
StartLine = 40,
|
||||
EndLine = 45,
|
||||
HighlightLine = 42,
|
||||
Language = "csharp",
|
||||
};
|
||||
|
||||
snippet.Code.Should().Contain("Execute");
|
||||
snippet.StartLine.Should().Be(40);
|
||||
snippet.EndLine.Should().Be(45);
|
||||
snippet.HighlightLine.Should().Be(42);
|
||||
snippet.Language.Should().Be("csharp");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static StackTraceExploitPathView CreateMinimalView(int frameCount = 3)
|
||||
{
|
||||
var frames = Enumerable.Range(0, frameCount)
|
||||
.Select(i => new StackTraceFrame
|
||||
{
|
||||
Index = i,
|
||||
Symbol = $"Frame_{i}",
|
||||
Role = i == 0 ? FrameRole.Entrypoint
|
||||
: i == frameCount - 1 ? FrameRole.Sink
|
||||
: FrameRole.Intermediate,
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
return new StackTraceExploitPathView
|
||||
{
|
||||
PathId = "path:test",
|
||||
Title = "Test Path",
|
||||
Frames = frames,
|
||||
Reachability = ReachabilityStatus.StaticallyReachable,
|
||||
CveIds = ["CVE-2024-99999"],
|
||||
};
|
||||
}
|
||||
|
||||
private static ExploitPath CreateExploitPath(string pathId = "path:test-001")
|
||||
{
|
||||
return new ExploitPath
|
||||
{
|
||||
PathId = pathId,
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
Package = new PackageRef("pkg:nuget/System.Data.SqlClient@4.8.0", "System.Data.SqlClient", "4.8.0", "nuget"),
|
||||
Symbol = new Models.VulnerableSymbol("SqlClient.Execute", "src/Data/SqlClient.cs", 42, "csharp"),
|
||||
EntryPoint = new EntryPoint("POST /api/orders", "HttpEndpoint", "/api/orders"),
|
||||
CveIds = ["CVE-2024-12345"],
|
||||
FindingIds = ["finding-001"],
|
||||
Reachability = ReachabilityStatus.StaticallyReachable,
|
||||
RiskScore = new PathRiskScore(9.8m, 0.5m, 1, 0, 0, 0),
|
||||
PriorityScore = 9.0m,
|
||||
Evidence = new PathEvidence(
|
||||
ReachabilityLatticeState.StaticallyReachable,
|
||||
VexStatus.Affected,
|
||||
0.85m,
|
||||
[new EvidenceItem("static_analysis", "call_graph", "Static call chain found", 0.85m)]),
|
||||
FirstSeenAt = FixedTime,
|
||||
LastUpdatedAt = FixedTime,
|
||||
};
|
||||
}
|
||||
|
||||
private static ExploitPath CreateExploitPathWithHighConfidence()
|
||||
{
|
||||
return CreateExploitPath() with
|
||||
{
|
||||
Evidence = new PathEvidence(
|
||||
ReachabilityLatticeState.RuntimeObserved,
|
||||
VexStatus.Affected,
|
||||
0.95m,
|
||||
[
|
||||
new EvidenceItem("static_analysis", "call_graph", "Static call chain found", 0.85m),
|
||||
new EvidenceItem("runtime_observation", "tracer", "Function invoked at runtime", 0.95m),
|
||||
]),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Triage.Tests/StellaOps.Scanner.Triage.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Add deterministic unit tests for exploit-path grouping and similarity threshold behavior (2026-02-08). |
|
||||
|
||||
@@ -6,3 +6,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/StellaOps.Scanner.WebService.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-062-VEXREACH-001 | DONE | Added deterministic unit coverage for VEX+reachability filter matrix and controller endpoint (`6` tests passed on filtered run, 2026-02-08). |
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Add endpoint tests for triage cluster inbox stats and batch triage actions (2026-02-08). |
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class TriageClusterEndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetClusterStats_ReturnsSeverityAndReachabilityDistributions()
|
||||
{
|
||||
var findings = BuildFindings();
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IFindingQueryService>();
|
||||
services.AddSingleton<IFindingQueryService>(new StubFindingQueryService(findings));
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/triage/inbox/clusters/stats?artifactDigest=sha256:test");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<TriageClusterStatsResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.TotalClusters.Should().Be(2);
|
||||
payload.TotalFindings.Should().Be(3);
|
||||
payload.SeverityDistribution["critical"].Should().Be(1);
|
||||
payload.ReachabilityDistribution["RuntimeConfirmed"].Should().Be(1);
|
||||
payload.ReachabilityDistribution["Unreachable"].Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PostClusterAction_AppliesActionToAllClusterFindings()
|
||||
{
|
||||
var findings = BuildFindings();
|
||||
var triageStatus = new StubTriageStatusService();
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IFindingQueryService>();
|
||||
services.RemoveAll<ITriageStatusService>();
|
||||
services.AddSingleton<IFindingQueryService>(new StubFindingQueryService(findings));
|
||||
services.AddSingleton<ITriageStatusService>(triageStatus);
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var statsResponse = await client.GetAsync("/api/v1/triage/inbox/clusters/stats?artifactDigest=sha256:test");
|
||||
var stats = await statsResponse.Content.ReadFromJsonAsync<TriageClusterStatsResponse>();
|
||||
var cluster = stats!.Clusters.Single(c => c.FindingCount == 2);
|
||||
|
||||
var actionRequest = new BatchTriageClusterActionRequest
|
||||
{
|
||||
ArtifactDigest = "sha256:test",
|
||||
DecisionKind = "MuteReach",
|
||||
Reason = "batch triage test"
|
||||
};
|
||||
|
||||
var actionResponse = await client.PostAsJsonAsync($"/api/v1/triage/inbox/clusters/{cluster.PathId}/actions", actionRequest);
|
||||
actionResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = await actionResponse.Content.ReadFromJsonAsync<BatchTriageClusterActionResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.RequestedFindingCount.Should().Be(2);
|
||||
payload.UpdatedFindingCount.Should().Be(2);
|
||||
payload.Lane.Should().Be("MutedReach");
|
||||
payload.DecisionKind.Should().Be("MuteReach");
|
||||
payload.ActionRecord.ActionRecordId.Should().StartWith("triage-action:");
|
||||
triageStatus.UpdatedFindingIds.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Finding> BuildFindings()
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero);
|
||||
return
|
||||
[
|
||||
new Finding(
|
||||
"finding-1",
|
||||
"pkg:npm/acme/a@1.0.0",
|
||||
"a",
|
||||
"1.0.0",
|
||||
["CVE-2026-0001"],
|
||||
9.0m,
|
||||
0.6m,
|
||||
Severity.Critical,
|
||||
"sha256:test",
|
||||
timestamp,
|
||||
["entry:http:post:/orders", "OrdersController.Post", "SqlSink.Write"],
|
||||
"entry:http:post:/orders",
|
||||
"SqlSink.Write",
|
||||
ReachabilityStatus.RuntimeConfirmed,
|
||||
0.95m),
|
||||
new Finding(
|
||||
"finding-2",
|
||||
"pkg:npm/acme/a@1.0.0",
|
||||
"a",
|
||||
"1.0.0",
|
||||
["CVE-2026-0002"],
|
||||
7.5m,
|
||||
0.4m,
|
||||
Severity.High,
|
||||
"sha256:test",
|
||||
timestamp,
|
||||
["entry:http:post:/orders", "OrdersController.Post", "KafkaSink.Publish"],
|
||||
"entry:http:post:/orders",
|
||||
"KafkaSink.Publish",
|
||||
ReachabilityStatus.StaticallyReachable,
|
||||
0.75m),
|
||||
new Finding(
|
||||
"finding-3",
|
||||
"pkg:npm/acme/b@2.0.0",
|
||||
"b",
|
||||
"2.0.0",
|
||||
["CVE-2026-0003"],
|
||||
3.0m,
|
||||
0.1m,
|
||||
Severity.Low,
|
||||
"sha256:test",
|
||||
timestamp,
|
||||
["entry:http:get:/health", "HealthController.Get", "LogSink.Write"],
|
||||
"entry:http:get:/health",
|
||||
"LogSink.Write",
|
||||
ReachabilityStatus.Unreachable,
|
||||
0.2m)
|
||||
];
|
||||
}
|
||||
|
||||
private sealed class StubFindingQueryService : IFindingQueryService
|
||||
{
|
||||
private readonly IReadOnlyList<Finding> _findings;
|
||||
|
||||
public StubFindingQueryService(IReadOnlyList<Finding> findings)
|
||||
{
|
||||
_findings = findings;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<Finding>>(
|
||||
_findings.Where(f => string.Equals(f.ArtifactDigest, artifactDigest, StringComparison.Ordinal)).ToArray());
|
||||
}
|
||||
|
||||
private sealed class StubTriageStatusService : ITriageStatusService
|
||||
{
|
||||
public List<string> UpdatedFindingIds { get; } = [];
|
||||
|
||||
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default)
|
||||
=> Task.FromResult<FindingTriageStatusDto?>(null);
|
||||
|
||||
public Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
|
||||
string findingId,
|
||||
UpdateTriageStatusRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
UpdatedFindingIds.Add(findingId);
|
||||
return Task.FromResult<UpdateTriageStatusResponseDto?>(new UpdateTriageStatusResponseDto
|
||||
{
|
||||
FindingId = findingId,
|
||||
PreviousLane = "Active",
|
||||
NewLane = request.Lane ?? "Active",
|
||||
PreviousVerdict = "Block",
|
||||
NewVerdict = "Block",
|
||||
SnapshotId = $"snap-{findingId}",
|
||||
AppliedAt = new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero)
|
||||
});
|
||||
}
|
||||
|
||||
public Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
|
||||
string findingId,
|
||||
SubmitVexStatementRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<SubmitVexStatementResponseDto?>(null);
|
||||
|
||||
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
|
||||
BulkTriageQueryRequestDto request,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(new BulkTriageQueryResponseDto
|
||||
{
|
||||
Findings = [],
|
||||
TotalCount = 0,
|
||||
NextCursor = null,
|
||||
Summary = new TriageSummaryDto
|
||||
{
|
||||
ByLane = new Dictionary<string, int>(),
|
||||
ByVerdict = new Dictionary<string, int>(),
|
||||
CanShipCount = 0,
|
||||
BlockingCount = 0
|
||||
}
|
||||
});
|
||||
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default)
|
||||
=> Task.FromResult(new TriageSummaryDto
|
||||
{
|
||||
ByLane = new Dictionary<string, int>(),
|
||||
ByVerdict = new Dictionary<string, int>(),
|
||||
CanShipCount = 0,
|
||||
BlockingCount = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexGateControllerFilterTests.cs
|
||||
// Sprint: SPRINT_20260208_062_Scanner_vex_decision_filter_with_reachability
|
||||
// Description: Unit tests for VEX reachability filtering endpoint logic.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Controllers;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class VexGateControllerFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void FilterByVexReachability_ValidRequest_ReturnsExpectedSummary()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var request = new VexReachabilityFilterRequest
|
||||
{
|
||||
Findings = new List<VexReachabilityFilterFindingDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
FindingId = "f-1",
|
||||
Cve = "CVE-2026-1001",
|
||||
VendorStatus = "not_affected",
|
||||
ReachabilityTier = "unreachable",
|
||||
ExistingDecision = "warn"
|
||||
},
|
||||
new()
|
||||
{
|
||||
FindingId = "f-2",
|
||||
Cve = "CVE-2026-1002",
|
||||
VendorStatus = "affected",
|
||||
ReachabilityTier = "confirmed",
|
||||
ExistingDecision = "warn"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = controller.FilterByVexReachability(request);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsType<VexReachabilityFilterResponse>(ok.Value);
|
||||
Assert.Equal(2, payload.Findings.Count);
|
||||
Assert.Equal(1, payload.Summary.Suppressed);
|
||||
Assert.Equal(1, payload.Summary.Elevated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterByVexReachability_InvalidVendorStatus_ReturnsBadRequest()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var request = new VexReachabilityFilterRequest
|
||||
{
|
||||
Findings = new List<VexReachabilityFilterFindingDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
FindingId = "f-invalid",
|
||||
Cve = "CVE-2026-1999",
|
||||
VendorStatus = "broken_status",
|
||||
ReachabilityTier = "confirmed",
|
||||
ExistingDecision = "warn"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = controller.FilterByVexReachability(request);
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
private static VexGateController CreateController()
|
||||
{
|
||||
var queryService = new Mock<IVexGateQueryService>(MockBehavior.Strict).Object;
|
||||
var filter = new VexReachabilityDecisionFilter();
|
||||
return new VexGateController(
|
||||
queryService,
|
||||
filter,
|
||||
NullLogger<VexGateController>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,104 @@ public sealed class VexGateEndpointsTests
|
||||
Assert.All(findings, f => Assert.Equal("Block", f.Decision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterByVexReachability_WithMatrixCases_ReturnsAnnotatedActions()
|
||||
{
|
||||
await using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new VexReachabilityFilterRequest
|
||||
{
|
||||
Findings = new List<VexReachabilityFilterFindingDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
FindingId = "f-1",
|
||||
Cve = "CVE-2026-0001",
|
||||
Purl = "pkg:npm/a@1.0.0",
|
||||
VendorStatus = "not_affected",
|
||||
ReachabilityTier = "unreachable",
|
||||
ExistingDecision = "warn"
|
||||
},
|
||||
new()
|
||||
{
|
||||
FindingId = "f-2",
|
||||
Cve = "CVE-2026-0002",
|
||||
Purl = "pkg:npm/b@1.0.0",
|
||||
VendorStatus = "affected",
|
||||
ReachabilityTier = "confirmed",
|
||||
ExistingDecision = "warn"
|
||||
},
|
||||
new()
|
||||
{
|
||||
FindingId = "f-3",
|
||||
Cve = "CVE-2026-0003",
|
||||
Purl = "pkg:npm/c@1.0.0",
|
||||
VendorStatus = "not_affected",
|
||||
ReachabilityTier = "confirmed",
|
||||
ExistingDecision = "pass"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync($"{BasePath}/vex-reachability/filter", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<VexReachabilityFilterResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(3, payload!.Findings.Count);
|
||||
Assert.Equal(1, payload.Summary.Suppressed);
|
||||
Assert.Equal(1, payload.Summary.Elevated);
|
||||
Assert.Equal(1, payload.Summary.FlagForReview);
|
||||
|
||||
var byId = payload.Findings.ToDictionary(f => f.FindingId, StringComparer.Ordinal);
|
||||
Assert.Equal("suppress", byId["f-1"].Action);
|
||||
Assert.Equal("pass", byId["f-1"].EffectiveDecision);
|
||||
Assert.Equal("elevate", byId["f-2"].Action);
|
||||
Assert.Equal("block", byId["f-2"].EffectiveDecision);
|
||||
Assert.Equal("flag_for_review", byId["f-3"].Action);
|
||||
Assert.Equal("warn", byId["f-3"].EffectiveDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterByVexReachability_WithInvalidTier_ReturnsBadRequest()
|
||||
{
|
||||
await using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new VexReachabilityFilterRequest
|
||||
{
|
||||
Findings = new List<VexReachabilityFilterFindingDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
FindingId = "f-invalid",
|
||||
Cve = "CVE-2026-0999",
|
||||
Purl = "pkg:npm/invalid@1.0.0",
|
||||
VendorStatus = "affected",
|
||||
ReachabilityTier = "tier-9000",
|
||||
ExistingDecision = "warn"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync($"{BasePath}/vex-reachability/filter", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private static VexGateResultsResponse CreateTestGateResults(
|
||||
string scanId,
|
||||
int blockedCount = 1,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexReachabilityDecisionFilterTests.cs
|
||||
// Sprint: SPRINT_20260208_062_Scanner_vex_decision_filter_with_reachability
|
||||
// Description: Unit tests for VEX + reachability decision matrix filtering.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class VexReachabilityDecisionFilterTests
|
||||
{
|
||||
private readonly VexReachabilityDecisionFilter _filter = new();
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_NotAffectedAndUnreachable_SuppressesToPass()
|
||||
{
|
||||
var input = CreateInput(
|
||||
findingId: "f-1",
|
||||
cve: "CVE-2026-0001",
|
||||
vendorStatus: VexStatus.NotAffected,
|
||||
tier: VexReachabilityTier.Unreachable,
|
||||
existingDecision: VexGateDecision.Warn);
|
||||
|
||||
var result = _filter.Evaluate(input);
|
||||
|
||||
Assert.Equal(VexReachabilityFilterAction.Suppress, result.Action);
|
||||
Assert.Equal(VexGateDecision.Pass, result.EffectiveDecision);
|
||||
Assert.Equal("not_affected+unreachable", result.MatrixRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_AffectedAndConfirmed_ElevatesToBlock()
|
||||
{
|
||||
var input = CreateInput(
|
||||
findingId: "f-2",
|
||||
cve: "CVE-2026-0002",
|
||||
vendorStatus: VexStatus.Affected,
|
||||
tier: VexReachabilityTier.Confirmed,
|
||||
existingDecision: VexGateDecision.Warn);
|
||||
|
||||
var result = _filter.Evaluate(input);
|
||||
|
||||
Assert.Equal(VexReachabilityFilterAction.Elevate, result.Action);
|
||||
Assert.Equal(VexGateDecision.Block, result.EffectiveDecision);
|
||||
Assert.Equal("affected+reachable", result.MatrixRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_NotAffectedAndConfirmed_FlagsForReview()
|
||||
{
|
||||
var input = CreateInput(
|
||||
findingId: "f-3",
|
||||
cve: "CVE-2026-0003",
|
||||
vendorStatus: VexStatus.NotAffected,
|
||||
tier: VexReachabilityTier.Confirmed,
|
||||
existingDecision: VexGateDecision.Pass);
|
||||
|
||||
var result = _filter.Evaluate(input);
|
||||
|
||||
Assert.Equal(VexReachabilityFilterAction.FlagForReview, result.Action);
|
||||
Assert.Equal(VexGateDecision.Warn, result.EffectiveDecision);
|
||||
Assert.Equal("not_affected+reachable", result.MatrixRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateBatch_PreservesInputOrderDeterministically()
|
||||
{
|
||||
var inputs = new[]
|
||||
{
|
||||
CreateInput("f-a", "CVE-A", VexStatus.NotAffected, VexReachabilityTier.Unreachable, VexGateDecision.Warn),
|
||||
CreateInput("f-b", "CVE-B", VexStatus.Affected, VexReachabilityTier.Likely, VexGateDecision.Warn),
|
||||
CreateInput("f-c", "CVE-C", null, VexReachabilityTier.Present, VexGateDecision.Pass)
|
||||
};
|
||||
|
||||
var results = _filter.EvaluateBatch(inputs);
|
||||
|
||||
Assert.Equal(3, results.Length);
|
||||
Assert.Equal("f-a", results[0].FindingId);
|
||||
Assert.Equal("f-b", results[1].FindingId);
|
||||
Assert.Equal("f-c", results[2].FindingId);
|
||||
Assert.Equal(VexReachabilityFilterAction.PassThrough, results[2].Action);
|
||||
Assert.Equal(VexGateDecision.Pass, results[2].EffectiveDecision);
|
||||
}
|
||||
|
||||
private static VexReachabilityDecisionInput CreateInput(
|
||||
string findingId,
|
||||
string cve,
|
||||
VexStatus? vendorStatus,
|
||||
VexReachabilityTier tier,
|
||||
VexGateDecision existingDecision)
|
||||
{
|
||||
return new VexReachabilityDecisionInput
|
||||
{
|
||||
FindingId = findingId,
|
||||
VulnerabilityId = cve,
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
VendorStatus = vendorStatus,
|
||||
ReachabilityTier = tier,
|
||||
ExistingDecision = existingDecision
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
24
src/Scanner/__Tests/__Datasets/toys/README.md
Normal file
24
src/Scanner/__Tests/__Datasets/toys/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Toy Service Reachability Corpus
|
||||
|
||||
This dataset provides deterministic toy services and `labels.yaml` files for
|
||||
reachability-tier benchmarking in Scanner tests.
|
||||
|
||||
## labels.yaml schema (v1)
|
||||
- `schema_version`: always `v1`
|
||||
- `service`: toy service directory name
|
||||
- `language`: primary language
|
||||
- `entrypoint`: relative source file used as app entrypoint
|
||||
- `cves`: list of CVE labels
|
||||
|
||||
Each CVE label contains:
|
||||
- `id`: CVE identifier
|
||||
- `package`: vulnerable package identifier
|
||||
- `tier`: one of `R0`, `R1`, `R2`, `R3`, `R4`
|
||||
- `rationale`: deterministic explanation for expected tier
|
||||
|
||||
Tier definitions:
|
||||
- `R0`: unreachable
|
||||
- `R1`: present in dependency only
|
||||
- `R2`: imported but not called
|
||||
- `R3`: called but not reachable from entrypoint
|
||||
- `R4`: reachable from entrypoint
|
||||
@@ -0,0 +1,9 @@
|
||||
schema_version: v1
|
||||
service: svc-01-log4shell-java
|
||||
language: java
|
||||
entrypoint: src/main/java/com/stellaops/toys/App.java
|
||||
cves:
|
||||
- id: CVE-2021-44228
|
||||
package: pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1
|
||||
tier: R4
|
||||
rationale: User-controlled logging path starts from main() and reaches sink.
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.stellaops.toys;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
public final class App {
|
||||
private static final Logger Log = LogManager.getLogger(App.class);
|
||||
|
||||
public static void main(String[] args) {
|
||||
String userInput = args.length > 0 ? args[0] : "default";
|
||||
// Simulates the vulnerable path being reachable from entrypoint.
|
||||
Log.error("User payload: {}", userInput);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
schema_version: v1
|
||||
service: svc-02-prototype-pollution-node
|
||||
language: node
|
||||
entrypoint: src/index.js
|
||||
cves:
|
||||
- id: CVE-2022-24999
|
||||
package: pkg:npm/qs@6.10.3
|
||||
tier: R2
|
||||
rationale: Package usage is imported-level only with no exploitable call path.
|
||||
@@ -0,0 +1,6 @@
|
||||
const defaults = { safe: true };
|
||||
const input = JSON.parse('{"__proto__": {"polluted": true}}');
|
||||
|
||||
// Import/package present and parsed, but no dangerous sink invocation.
|
||||
Object.assign(defaults, input);
|
||||
console.log(defaults.safe);
|
||||
@@ -0,0 +1,11 @@
|
||||
import pickle
|
||||
|
||||
# Vulnerable helper exists, but entrypoint never routes attacker input into it.
|
||||
def unsafe_deserialize(data: bytes):
|
||||
return pickle.loads(data)
|
||||
|
||||
def main():
|
||||
print("health check")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
schema_version: v1
|
||||
service: svc-03-pickle-deserialization-python
|
||||
language: python
|
||||
entrypoint: app.py
|
||||
cves:
|
||||
- id: CVE-2011-2526
|
||||
package: pkg:pypi/pickle@0
|
||||
tier: R3
|
||||
rationale: Vulnerable function is called in codebase but not reachable from main().
|
||||
@@ -0,0 +1,9 @@
|
||||
schema_version: v1
|
||||
service: svc-04-text-template-go
|
||||
language: go
|
||||
entrypoint: main.go
|
||||
cves:
|
||||
- id: CVE-2023-24538
|
||||
package: pkg:golang/text/template@1.20.0
|
||||
tier: R1
|
||||
rationale: Vulnerable package is present in dependency graph with no import usage.
|
||||
@@ -0,0 +1,8 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
// Dependency is present but only linked transitively in this toy service.
|
||||
fmt.Println("template demo")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static void Main()
|
||||
{
|
||||
Console.WriteLine(typeof(XmlSerializer).Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
schema_version: v1
|
||||
service: svc-05-xmlserializer-dotnet
|
||||
language: dotnet
|
||||
entrypoint: Program.cs
|
||||
cves:
|
||||
- id: CVE-2021-26701
|
||||
package: pkg:nuget/system.xml.xmlserializer@4.3.0
|
||||
tier: R0
|
||||
rationale: Vulnerable pattern is not present and no reachable sink path exists.
|
||||
@@ -0,0 +1,9 @@
|
||||
require "erb"
|
||||
|
||||
def render(payload)
|
||||
ERB.new(payload).result(binding)
|
||||
end
|
||||
|
||||
if __FILE__ == $PROGRAM_NAME
|
||||
puts render("Hello <%= \"world\" %>")
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
schema_version: v1
|
||||
service: svc-06-erb-injection-ruby
|
||||
language: ruby
|
||||
entrypoint: app.rb
|
||||
cves:
|
||||
- id: CVE-2021-41819
|
||||
package: pkg:gem/erb@2.7.0
|
||||
tier: R4
|
||||
rationale: Entry script invokes ERB rendering directly with user-controlled template input.
|
||||
Reference in New Issue
Block a user