partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -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; }
}

View File

@@ -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>