partly or unimplemented features - now implemented
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user