feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -0,0 +1,309 @@
// -----------------------------------------------------------------------------
// ActionablesEndpoints.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: HTTP endpoints for actionable remediation recommendations.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for actionable remediation recommendations.
/// Per SPRINT_4200_0002_0006 T3.
/// </summary>
internal static class ActionablesEndpoints
{
/// <summary>
/// Maps actionables endpoints.
/// </summary>
public static void MapActionablesEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/actionables")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var group = apiGroup.MapGroup(prefix)
.WithTags("Actionables");
// GET /v1/actionables/delta/{deltaId} - Get actionables for a delta
group.MapGet("/delta/{deltaId}", HandleGetDeltaActionablesAsync)
.WithName("scanner.actionables.delta")
.WithDescription("Get actionable recommendations for a delta comparison.")
.Produces<ActionablesResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/actionables/delta/{deltaId}/by-priority/{priority} - Filter by priority
group.MapGet("/delta/{deltaId}/by-priority/{priority}", HandleGetActionablesByPriorityAsync)
.WithName("scanner.actionables.by-priority")
.WithDescription("Get actionables filtered by priority level.")
.Produces<ActionablesResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/actionables/delta/{deltaId}/by-type/{type} - Filter by type
group.MapGet("/delta/{deltaId}/by-type/{type}", HandleGetActionablesByTypeAsync)
.WithName("scanner.actionables.by-type")
.WithDescription("Get actionables filtered by action type.")
.Produces<ActionablesResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleGetDeltaActionablesAsync(
string deltaId,
IActionablesService actionablesService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(actionablesService);
if (string.IsNullOrWhiteSpace(deltaId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid delta ID",
detail = "Delta ID is required."
});
}
var actionables = await actionablesService.GenerateForDeltaAsync(deltaId, cancellationToken);
if (actionables is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Delta not found",
detail = $"Delta with ID '{deltaId}' was not found."
});
}
return Results.Ok(actionables);
}
private static async Task<IResult> HandleGetActionablesByPriorityAsync(
string deltaId,
string priority,
IActionablesService actionablesService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(actionablesService);
var validPriorities = new[] { "critical", "high", "medium", "low" };
if (!validPriorities.Contains(priority, StringComparer.OrdinalIgnoreCase))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid priority",
detail = $"Priority must be one of: {string.Join(", ", validPriorities)}"
});
}
var allActionables = await actionablesService.GenerateForDeltaAsync(deltaId, cancellationToken);
if (allActionables is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Delta not found",
detail = $"Delta with ID '{deltaId}' was not found."
});
}
var filtered = allActionables.Actionables
.Where(a => a.Priority.Equals(priority, StringComparison.OrdinalIgnoreCase))
.ToList();
return Results.Ok(new ActionablesResponseDto
{
DeltaId = deltaId,
Actionables = filtered,
GeneratedAt = allActionables.GeneratedAt
});
}
private static async Task<IResult> HandleGetActionablesByTypeAsync(
string deltaId,
string type,
IActionablesService actionablesService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(actionablesService);
var validTypes = new[] { "upgrade", "patch", "vex", "config", "investigate" };
if (!validTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid type",
detail = $"Type must be one of: {string.Join(", ", validTypes)}"
});
}
var allActionables = await actionablesService.GenerateForDeltaAsync(deltaId, cancellationToken);
if (allActionables is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Delta not found",
detail = $"Delta with ID '{deltaId}' was not found."
});
}
var filtered = allActionables.Actionables
.Where(a => a.Type.Equals(type, StringComparison.OrdinalIgnoreCase))
.ToList();
return Results.Ok(new ActionablesResponseDto
{
DeltaId = deltaId,
Actionables = filtered,
GeneratedAt = allActionables.GeneratedAt
});
}
}
/// <summary>
/// Service interface for actionables generation.
/// Per SPRINT_4200_0002_0006 T3.
/// </summary>
public interface IActionablesService
{
/// <summary>
/// Generates actionable recommendations for a delta.
/// </summary>
Task<ActionablesResponseDto?> GenerateForDeltaAsync(string deltaId, CancellationToken ct = default);
}
/// <summary>
/// Default implementation of actionables service.
/// </summary>
public sealed class ActionablesService : IActionablesService
{
private readonly TimeProvider _timeProvider;
private readonly IDeltaCompareService _deltaService;
public ActionablesService(TimeProvider timeProvider, IDeltaCompareService deltaService)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_deltaService = deltaService ?? throw new ArgumentNullException(nameof(deltaService));
}
public async Task<ActionablesResponseDto?> GenerateForDeltaAsync(string deltaId, CancellationToken ct = default)
{
// In a full implementation, this would retrieve the delta and generate
// actionables based on the findings. For now, return sample actionables.
var delta = await _deltaService.GetComparisonAsync(deltaId, ct);
// Even if delta is null, we can still generate sample actionables for demo
var actionables = new List<ActionableDto>();
// Sample upgrade actionable
actionables.Add(new ActionableDto
{
Id = $"action-upgrade-{deltaId[..8]}",
Type = "upgrade",
Priority = "critical",
Title = "Upgrade log4j to fix CVE-2021-44228",
Description = "Upgrade log4j from 2.14.1 to 2.17.1 to remediate the Log4Shell vulnerability. " +
"This is a critical remote code execution vulnerability.",
Component = "pkg:maven/org.apache.logging.log4j/log4j-core",
CurrentVersion = "2.14.1",
TargetVersion = "2.17.1",
CveIds = ["CVE-2021-44228", "CVE-2021-45046"],
EstimatedEffort = "low",
Evidence = new ActionableEvidenceDto
{
PolicyRuleId = "rule-critical-cve"
}
});
// Sample VEX actionable
actionables.Add(new ActionableDto
{
Id = $"action-vex-{deltaId[..8]}",
Type = "vex",
Priority = "high",
Title = "Submit VEX statement for CVE-2023-12345",
Description = "Reachability analysis shows the vulnerable function is not called. " +
"Consider submitting a VEX statement with status 'not_affected' and justification " +
"'vulnerable_code_not_in_execute_path'.",
Component = "pkg:npm/example-lib",
CveIds = ["CVE-2023-12345"],
EstimatedEffort = "trivial",
Evidence = new ActionableEvidenceDto
{
WitnessId = "witness-12345"
}
});
// Sample investigate actionable
actionables.Add(new ActionableDto
{
Id = $"action-investigate-{deltaId[..8]}",
Type = "investigate",
Priority = "medium",
Title = "Review reachability change for CVE-2023-67890",
Description = "Code path reachability changed from 'No' to 'Yes'. Review if the vulnerable " +
"function is now actually reachable from an entrypoint.",
Component = "pkg:pypi/requests",
CveIds = ["CVE-2023-67890"],
EstimatedEffort = "medium",
Evidence = new ActionableEvidenceDto
{
WitnessId = "witness-67890"
}
});
// Sample config actionable
actionables.Add(new ActionableDto
{
Id = $"action-config-{deltaId[..8]}",
Type = "config",
Priority = "low",
Title = "New component detected: review security requirements",
Description = "New dependency 'pkg:npm/axios@1.6.0' was added. Verify it meets security " +
"requirements and is from a trusted source.",
Component = "pkg:npm/axios",
CurrentVersion = "1.6.0",
EstimatedEffort = "trivial"
});
// Sort by priority
var sortedActionables = actionables
.OrderBy(a => GetPriorityOrder(a.Priority))
.ThenBy(a => a.Title, StringComparer.Ordinal)
.ToList();
return new ActionablesResponseDto
{
DeltaId = deltaId,
Actionables = sortedActionables,
GeneratedAt = _timeProvider.GetUtcNow()
};
}
private static int GetPriorityOrder(string priority)
{
return priority.ToLowerInvariant() switch
{
"critical" => 0,
"high" => 1,
"medium" => 2,
"low" => 3,
_ => 4
};
}
}

View File

@@ -0,0 +1,292 @@
// -----------------------------------------------------------------------------
// BaselineEndpoints.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: HTTP endpoints for baseline selection and rationale.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for baseline selection with rationale.
/// Per SPRINT_4200_0002_0006 T1.
/// </summary>
internal static class BaselineEndpoints
{
/// <summary>
/// Maps baseline selection endpoints.
/// </summary>
public static void MapBaselineEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/baselines")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var group = apiGroup.MapGroup(prefix)
.WithTags("Baselines");
// GET /v1/baselines/recommendations/{artifactDigest} - Get recommended baselines
group.MapGet("/recommendations/{artifactDigest}", HandleGetRecommendationsAsync)
.WithName("scanner.baselines.recommendations")
.WithDescription("Get recommended baselines for an artifact with rationale.")
.Produces<BaselineRecommendationsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/baselines/rationale/{baseDigest}/{headDigest} - Get selection rationale
group.MapGet("/rationale/{baseDigest}/{headDigest}", HandleGetRationaleAsync)
.WithName("scanner.baselines.rationale")
.WithDescription("Get detailed rationale for a baseline selection.")
.Produces<BaselineRationaleResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleGetRecommendationsAsync(
string artifactDigest,
IBaselineService baselineService,
HttpContext context,
string? environment = null,
string? policyId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(baselineService);
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid artifact digest",
detail = "Artifact digest is required."
});
}
var recommendations = await baselineService.GetRecommendationsAsync(
artifactDigest,
environment,
policyId,
cancellationToken);
return Results.Ok(recommendations);
}
private static async Task<IResult> HandleGetRationaleAsync(
string baseDigest,
string headDigest,
IBaselineService baselineService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(baselineService);
if (string.IsNullOrWhiteSpace(baseDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid base digest",
detail = "Base digest is required."
});
}
if (string.IsNullOrWhiteSpace(headDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid head digest",
detail = "Head digest is required."
});
}
var rationale = await baselineService.GetRationaleAsync(baseDigest, headDigest, cancellationToken);
if (rationale is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Baseline not found",
detail = $"No baseline found for base '{baseDigest}' and head '{headDigest}'."
});
}
return Results.Ok(rationale);
}
}
/// <summary>
/// Service interface for baseline selection operations.
/// Per SPRINT_4200_0002_0006 T1.
/// </summary>
public interface IBaselineService
{
/// <summary>
/// Gets recommended baselines for an artifact.
/// </summary>
Task<BaselineRecommendationsResponseDto> GetRecommendationsAsync(
string artifactDigest,
string? environment,
string? policyId,
CancellationToken ct = default);
/// <summary>
/// Gets detailed rationale for a baseline selection.
/// </summary>
Task<BaselineRationaleResponseDto?> GetRationaleAsync(
string baseDigest,
string headDigest,
CancellationToken ct = default);
}
/// <summary>
/// Default implementation of baseline selection service.
/// </summary>
public sealed class BaselineService : IBaselineService
{
private readonly TimeProvider _timeProvider;
public BaselineService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<BaselineRecommendationsResponseDto> GetRecommendationsAsync(
string artifactDigest,
string? environment,
string? policyId,
CancellationToken ct = default)
{
var recommendations = new List<BaselineRecommendationDto>();
// In a full implementation, this would query the scan repository
// to find actual baselines. For now, return a structured response.
// Recommendation 1: Last green build (default)
recommendations.Add(new BaselineRecommendationDto
{
Id = "last-green",
Type = "last-green",
Label = "Last Green Build",
Digest = $"sha256:baseline-{artifactDigest[..8]}",
Timestamp = _timeProvider.GetUtcNow().AddDays(-1),
Rationale = $"Selected last prod release with Allowed verdict under current policy{(policyId is not null ? $" ({policyId})" : "")}.",
VerdictStatus = "allowed",
PolicyVersion = "1.0.0",
IsDefault = true
});
// Recommendation 2: Previous release
recommendations.Add(new BaselineRecommendationDto
{
Id = "previous-release",
Type = "previous-release",
Label = "Previous Release (v1.2.3)",
Digest = $"sha256:release-{artifactDigest[..8]}",
Timestamp = _timeProvider.GetUtcNow().AddDays(-7),
Rationale = "Previous release tag: v1.2.3",
VerdictStatus = "allowed",
PolicyVersion = "1.0.0",
IsDefault = false
});
// Recommendation 3: Parent commit
recommendations.Add(new BaselineRecommendationDto
{
Id = "parent-commit",
Type = "main-branch",
Label = "Parent Commit",
Digest = $"sha256:parent-{artifactDigest[..8]}",
Timestamp = _timeProvider.GetUtcNow().AddHours(-2),
Rationale = "Parent commit on main branch: abc12345",
VerdictStatus = "allowed",
PolicyVersion = "1.0.0",
IsDefault = false
});
var response = new BaselineRecommendationsResponseDto
{
ArtifactDigest = artifactDigest,
Recommendations = recommendations,
GeneratedAt = _timeProvider.GetUtcNow()
};
return Task.FromResult(response);
}
public Task<BaselineRationaleResponseDto?> GetRationaleAsync(
string baseDigest,
string headDigest,
CancellationToken ct = default)
{
// In a full implementation, this would look up actual scan data
// and determine the selection type. For now, return a structured response.
var selectionType = DetermineSelectionType(baseDigest);
var rationale = GenerateRationale(selectionType);
var explanation = GenerateDetailedExplanation(selectionType);
var response = new BaselineRationaleResponseDto
{
BaseDigest = baseDigest,
HeadDigest = headDigest,
SelectionType = selectionType,
Rationale = rationale,
DetailedExplanation = explanation,
SelectionCriteria = GetSelectionCriteria(selectionType),
BaseTimestamp = _timeProvider.GetUtcNow().AddDays(-1),
HeadTimestamp = _timeProvider.GetUtcNow()
};
return Task.FromResult<BaselineRationaleResponseDto?>(response);
}
private static string DetermineSelectionType(string baseDigest)
{
// Logic to determine how baseline was selected
if (baseDigest.Contains("baseline", StringComparison.OrdinalIgnoreCase))
return "last-green";
if (baseDigest.Contains("release", StringComparison.OrdinalIgnoreCase))
return "previous-release";
return "manual";
}
private static string GenerateRationale(string selectionType)
{
return selectionType switch
{
"last-green" => "Selected last prod release with Allowed verdict under current policy.",
"previous-release" => "Selected previous release tag for version comparison.",
"manual" => "User manually selected this baseline for comparison.",
_ => "Baseline selected for comparison."
};
}
private static string GenerateDetailedExplanation(string selectionType)
{
return selectionType switch
{
"last-green" =>
"This baseline was automatically selected because it represents the most recent scan " +
"that received an 'Allowed' verdict under the current policy. This ensures you're " +
"comparing against a known-good state that passed all security gates.",
"previous-release" =>
"This baseline corresponds to the previous release tag in your version history. " +
"Comparing against the previous release helps identify what changed between versions.",
_ => "This baseline was manually selected for comparison."
};
}
private static IReadOnlyList<string> GetSelectionCriteria(string selectionType)
{
return selectionType switch
{
"last-green" => ["Verdict = Allowed", "Same environment", "Most recent"],
"previous-release" => ["Has release tag", "Previous in version order"],
_ => []
};
}
}

View File

@@ -0,0 +1,612 @@
// -----------------------------------------------------------------------------
// CounterfactualEndpoints.cs
// Sprint: SPRINT_4200_0002_0005_counterfactuals
// Description: HTTP endpoints for policy counterfactual analysis.
// -----------------------------------------------------------------------------
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Policy.Counterfactuals;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for policy counterfactual analysis.
/// Per SPRINT_4200_0002_0005 T7.
/// </summary>
internal static class CounterfactualEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
/// <summary>
/// Maps counterfactual analysis endpoints.
/// </summary>
public static void MapCounterfactualEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/counterfactuals")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var group = apiGroup.MapGroup(prefix)
.WithTags("Counterfactuals");
// POST /v1/counterfactuals/compute - Compute counterfactuals for a finding
group.MapPost("/compute", HandleComputeAsync)
.WithName("scanner.counterfactuals.compute")
.WithDescription("Compute counterfactual paths for a blocked finding.")
.Produces<CounterfactualResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/counterfactuals/finding/{findingId} - Get counterfactuals for a finding
group.MapGet("/finding/{findingId}", HandleGetForFindingAsync)
.WithName("scanner.counterfactuals.finding")
.WithDescription("Get computed counterfactuals for a specific finding.")
.Produces<CounterfactualResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/counterfactuals/scan/{scanId}/summary - Get counterfactual summary for scan
group.MapGet("/scan/{scanId}/summary", HandleGetScanSummaryAsync)
.WithName("scanner.counterfactuals.scan-summary")
.WithDescription("Get counterfactual summary for all blocked findings in a scan.")
.Produces<CounterfactualScanSummaryDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleComputeAsync(
CounterfactualRequestDto request,
ICounterfactualApiService counterfactualService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(counterfactualService);
if (request is null)
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid request",
detail = "Request body is required."
});
}
if (string.IsNullOrWhiteSpace(request.FindingId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid finding ID",
detail = "Finding ID is required."
});
}
var result = await counterfactualService.ComputeAsync(request, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> HandleGetForFindingAsync(
string findingId,
ICounterfactualApiService counterfactualService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(counterfactualService);
if (string.IsNullOrWhiteSpace(findingId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid finding ID",
detail = "Finding ID is required."
});
}
var result = await counterfactualService.GetForFindingAsync(findingId, cancellationToken);
if (result is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Counterfactuals not found",
detail = $"No counterfactuals found for finding '{findingId}'."
});
}
return Results.Ok(result);
}
private static async Task<IResult> HandleGetScanSummaryAsync(
string scanId,
ICounterfactualApiService counterfactualService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(counterfactualService);
if (string.IsNullOrWhiteSpace(scanId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid scan ID",
detail = "Scan ID is required."
});
}
var result = await counterfactualService.GetScanSummaryAsync(scanId, cancellationToken);
if (result is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Scan not found",
detail = $"Scan '{scanId}' was not found."
});
}
return Results.Ok(result);
}
}
#region DTOs
/// <summary>
/// Request to compute counterfactuals for a finding.
/// </summary>
public sealed record CounterfactualRequestDto
{
/// <summary>
/// Finding ID to analyze.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Vulnerability ID (CVE).
/// </summary>
public string? VulnId { get; init; }
/// <summary>
/// Component PURL.
/// </summary>
public string? Purl { get; init; }
/// <summary>
/// Current verdict (Block, Ship, etc.).
/// </summary>
public string? CurrentVerdict { get; init; }
/// <summary>
/// Current VEX status if any.
/// </summary>
public string? VexStatus { get; init; }
/// <summary>
/// Current reachability status if any.
/// </summary>
public string? Reachability { get; init; }
/// <summary>
/// Maximum number of paths to return.
/// </summary>
public int? MaxPaths { get; init; }
}
/// <summary>
/// Response containing computed counterfactuals.
/// </summary>
public sealed record CounterfactualResponseDto
{
/// <summary>
/// Finding ID analyzed.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Current verdict.
/// </summary>
public required string CurrentVerdict { get; init; }
/// <summary>
/// Whether counterfactuals could be computed.
/// </summary>
public bool HasPaths { get; init; }
/// <summary>
/// List of counterfactual paths to achieve Ship verdict.
/// </summary>
public required IReadOnlyList<CounterfactualPathDto> Paths { get; init; }
/// <summary>
/// Human-readable suggestions.
/// </summary>
public required IReadOnlyList<string> WouldPassIf { get; init; }
/// <summary>
/// When this was computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// A single counterfactual path.
/// </summary>
public sealed record CounterfactualPathDto
{
/// <summary>
/// Type of counterfactual: Vex, Exception, Reachability, VersionUpgrade, etc.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Human-readable description of the path.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Conditions that must be met.
/// </summary>
public required IReadOnlyList<CounterfactualConditionDto> Conditions { get; init; }
/// <summary>
/// Estimated effort: trivial, low, medium, high.
/// </summary>
public string? Effort { get; init; }
/// <summary>
/// Confidence that this path would work (0-1).
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// Whether this path is recommended.
/// </summary>
public bool IsRecommended { get; init; }
}
/// <summary>
/// A condition within a counterfactual path.
/// </summary>
public sealed record CounterfactualConditionDto
{
/// <summary>
/// Field or attribute that must change.
/// </summary>
public required string Field { get; init; }
/// <summary>
/// Current value.
/// </summary>
public string? CurrentValue { get; init; }
/// <summary>
/// Required value.
/// </summary>
public required string RequiredValue { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Summary of counterfactuals for a scan.
/// </summary>
public sealed record CounterfactualScanSummaryDto
{
/// <summary>
/// Scan ID.
/// </summary>
public required string ScanId { get; init; }
/// <summary>
/// Total blocked findings.
/// </summary>
public int TotalBlocked { get; init; }
/// <summary>
/// Findings with VEX paths.
/// </summary>
public int WithVexPath { get; init; }
/// <summary>
/// Findings with reachability paths.
/// </summary>
public int WithReachabilityPath { get; init; }
/// <summary>
/// Findings with upgrade paths.
/// </summary>
public int WithUpgradePath { get; init; }
/// <summary>
/// Findings with exception paths.
/// </summary>
public int WithExceptionPath { get; init; }
/// <summary>
/// Per-finding summaries.
/// </summary>
public required IReadOnlyList<CounterfactualFindingSummaryDto> Findings { get; init; }
/// <summary>
/// When this was computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Summary for a single finding.
/// </summary>
public sealed record CounterfactualFindingSummaryDto
{
/// <summary>
/// Finding ID.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Vulnerability ID.
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Component PURL.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Number of paths available.
/// </summary>
public int PathCount { get; init; }
/// <summary>
/// Easiest path type.
/// </summary>
public string? EasiestPath { get; init; }
/// <summary>
/// Would pass if suggestions.
/// </summary>
public required IReadOnlyList<string> WouldPassIf { get; init; }
}
#endregion
#region Service Interface
/// <summary>
/// Service interface for counterfactual API operations.
/// Per SPRINT_4200_0002_0005 T7.
/// </summary>
public interface ICounterfactualApiService
{
/// <summary>
/// Computes counterfactuals for a finding.
/// </summary>
Task<CounterfactualResponseDto> ComputeAsync(CounterfactualRequestDto request, CancellationToken ct = default);
/// <summary>
/// Gets cached counterfactuals for a finding.
/// </summary>
Task<CounterfactualResponseDto?> GetForFindingAsync(string findingId, CancellationToken ct = default);
/// <summary>
/// Gets counterfactual summary for a scan.
/// </summary>
Task<CounterfactualScanSummaryDto?> GetScanSummaryAsync(string scanId, CancellationToken ct = default);
}
/// <summary>
/// Default implementation of counterfactual API service.
/// </summary>
public sealed class CounterfactualApiService : ICounterfactualApiService
{
private readonly TimeProvider _timeProvider;
private readonly ICounterfactualEngine? _counterfactualEngine;
public CounterfactualApiService(TimeProvider timeProvider, ICounterfactualEngine? counterfactualEngine = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_counterfactualEngine = counterfactualEngine;
}
public Task<CounterfactualResponseDto> ComputeAsync(CounterfactualRequestDto request, CancellationToken ct = default)
{
var paths = new List<CounterfactualPathDto>();
var wouldPassIf = new List<string>();
// Generate counterfactual paths based on the finding
// VEX path
if (string.IsNullOrEmpty(request.VexStatus) ||
!request.VexStatus.Equals("not_affected", StringComparison.OrdinalIgnoreCase))
{
paths.Add(new CounterfactualPathDto
{
Type = "Vex",
Description = "Submit VEX statement marking vulnerability as not affecting this component",
Conditions =
[
new CounterfactualConditionDto
{
Field = "vex_status",
CurrentValue = request.VexStatus ?? "unknown",
RequiredValue = "not_affected",
Description = "VEX status must be 'not_affected'"
}
],
Effort = "low",
Confidence = 0.95,
IsRecommended = true
});
wouldPassIf.Add("VEX status changed to 'not_affected'");
}
// Reachability path
if (string.IsNullOrEmpty(request.Reachability) ||
!request.Reachability.Equals("no", StringComparison.OrdinalIgnoreCase))
{
paths.Add(new CounterfactualPathDto
{
Type = "Reachability",
Description = "Reachability analysis shows vulnerable code is not reachable",
Conditions =
[
new CounterfactualConditionDto
{
Field = "reachability",
CurrentValue = request.Reachability ?? "unknown",
RequiredValue = "no",
Description = "Vulnerable code must not be reachable from entrypoints"
}
],
Effort = "trivial",
Confidence = 0.9,
IsRecommended = true
});
wouldPassIf.Add("Reachability analysis shows code is not reachable");
}
// Version upgrade path
if (!string.IsNullOrWhiteSpace(request.VulnId))
{
paths.Add(new CounterfactualPathDto
{
Type = "VersionUpgrade",
Description = $"Upgrade component to a version without {request.VulnId}",
Conditions =
[
new CounterfactualConditionDto
{
Field = "version",
CurrentValue = ExtractVersion(request.Purl),
RequiredValue = "fixed_version",
Description = $"Component must be upgraded to version that fixes {request.VulnId}"
}
],
Effort = "medium",
Confidence = 1.0,
IsRecommended = false
});
wouldPassIf.Add($"Component upgraded to version without {request.VulnId}");
}
// Exception path
paths.Add(new CounterfactualPathDto
{
Type = "Exception",
Description = "Security exception granted with compensating controls",
Conditions =
[
new CounterfactualConditionDto
{
Field = "exception_status",
CurrentValue = "none",
RequiredValue = "granted",
Description = "Security team must grant an exception"
}
],
Effort = "high",
Confidence = 1.0,
IsRecommended = false
});
wouldPassIf.Add("Security exception is granted");
// Limit paths if requested
var maxPaths = request.MaxPaths ?? 10;
var limitedPaths = paths.Take(maxPaths).ToList();
var response = new CounterfactualResponseDto
{
FindingId = request.FindingId,
CurrentVerdict = request.CurrentVerdict ?? "Block",
HasPaths = limitedPaths.Count > 0,
Paths = limitedPaths,
WouldPassIf = wouldPassIf,
ComputedAt = _timeProvider.GetUtcNow()
};
return Task.FromResult(response);
}
public Task<CounterfactualResponseDto?> GetForFindingAsync(string findingId, CancellationToken ct = default)
{
// In a full implementation, this would retrieve cached results
// For now, compute on the fly
var request = new CounterfactualRequestDto
{
FindingId = findingId
};
return ComputeAsync(request, ct)!;
}
public Task<CounterfactualScanSummaryDto?> GetScanSummaryAsync(string scanId, CancellationToken ct = default)
{
// In a full implementation, this would retrieve actual scan findings
// For now, return sample data
var findings = new List<CounterfactualFindingSummaryDto>
{
new()
{
FindingId = $"{scanId}-finding-1",
VulnId = "CVE-2021-44228",
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
PathCount = 4,
EasiestPath = "Reachability",
WouldPassIf = ["Reachability analysis shows code is not reachable", "VEX status changed to 'not_affected'"]
},
new()
{
FindingId = $"{scanId}-finding-2",
VulnId = "CVE-2023-12345",
Purl = "pkg:npm/example-lib@1.0.0",
PathCount = 3,
EasiestPath = "Vex",
WouldPassIf = ["VEX status changed to 'not_affected'"]
}
};
var summary = new CounterfactualScanSummaryDto
{
ScanId = scanId,
TotalBlocked = findings.Count,
WithVexPath = findings.Count,
WithReachabilityPath = 1,
WithUpgradePath = findings.Count,
WithExceptionPath = findings.Count,
Findings = findings,
ComputedAt = _timeProvider.GetUtcNow()
};
return Task.FromResult<CounterfactualScanSummaryDto?>(summary);
}
private static string? ExtractVersion(string? purl)
{
if (string.IsNullOrWhiteSpace(purl))
return null;
var atIndex = purl.LastIndexOf('@');
if (atIndex < 0)
return null;
var version = purl[(atIndex + 1)..];
var questionIndex = version.IndexOf('?');
return questionIndex >= 0 ? version[..questionIndex] : version;
}
}
#endregion

View File

@@ -0,0 +1,291 @@
// -----------------------------------------------------------------------------
// DeltaCompareEndpoints.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: HTTP endpoints for delta/compare view API.
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for delta/compare view - comparing scan snapshots.
/// Per SPRINT_4200_0002_0006.
/// </summary>
internal static class DeltaCompareEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
/// <summary>
/// Maps delta compare endpoints.
/// </summary>
public static void MapDeltaCompareEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/delta")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var group = apiGroup.MapGroup(prefix)
.WithTags("DeltaCompare");
// POST /v1/delta/compare - Full comparison between two snapshots
group.MapPost("/compare", HandleCompareAsync)
.WithName("scanner.delta.compare")
.WithDescription("Compares two scan snapshots and returns detailed delta.")
.Produces<DeltaCompareResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/delta/quick - Quick summary for header display
group.MapGet("/quick", HandleQuickDiffAsync)
.WithName("scanner.delta.quick")
.WithDescription("Returns quick diff summary for Can I Ship header.")
.Produces<QuickDiffSummaryDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/delta/{comparisonId} - Get cached comparison by ID
group.MapGet("/{comparisonId}", HandleGetComparisonAsync)
.WithName("scanner.delta.get")
.WithDescription("Retrieves a cached comparison result by ID.")
.Produces<DeltaCompareResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleCompareAsync(
DeltaCompareRequestDto request,
IDeltaCompareService compareService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(compareService);
if (string.IsNullOrWhiteSpace(request.BaseDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid base digest",
detail = "Base digest is required."
});
}
if (string.IsNullOrWhiteSpace(request.TargetDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid target digest",
detail = "Target digest is required."
});
}
var result = await compareService.CompareAsync(request, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> HandleQuickDiffAsync(
string baseDigest,
string targetDigest,
IDeltaCompareService compareService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(compareService);
if (string.IsNullOrWhiteSpace(baseDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid base digest",
detail = "Base digest is required."
});
}
if (string.IsNullOrWhiteSpace(targetDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid target digest",
detail = "Target digest is required."
});
}
var result = await compareService.GetQuickDiffAsync(baseDigest, targetDigest, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> HandleGetComparisonAsync(
string comparisonId,
IDeltaCompareService compareService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(compareService);
if (string.IsNullOrWhiteSpace(comparisonId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid comparison ID",
detail = "Comparison ID is required."
});
}
var result = await compareService.GetComparisonAsync(comparisonId, cancellationToken);
if (result is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Comparison not found",
detail = $"Comparison with ID '{comparisonId}' was not found or has expired."
});
}
return Results.Ok(result);
}
}
/// <summary>
/// Service interface for delta compare operations.
/// Per SPRINT_4200_0002_0006.
/// </summary>
public interface IDeltaCompareService
{
/// <summary>
/// Performs a full comparison between two snapshots.
/// </summary>
Task<DeltaCompareResponseDto> CompareAsync(DeltaCompareRequestDto request, CancellationToken ct = default);
/// <summary>
/// Gets a quick diff summary for the Can I Ship header.
/// </summary>
Task<QuickDiffSummaryDto> GetQuickDiffAsync(string baseDigest, string targetDigest, CancellationToken ct = default);
/// <summary>
/// Gets a cached comparison by ID.
/// </summary>
Task<DeltaCompareResponseDto?> GetComparisonAsync(string comparisonId, CancellationToken ct = default);
}
/// <summary>
/// Default implementation of delta compare service.
/// </summary>
public sealed class DeltaCompareService : IDeltaCompareService
{
private readonly TimeProvider _timeProvider;
public DeltaCompareService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<DeltaCompareResponseDto> CompareAsync(DeltaCompareRequestDto request, CancellationToken ct = default)
{
// Compute deterministic comparison ID
var comparisonId = ComputeComparisonId(request.BaseDigest, request.TargetDigest);
// In a full implementation, this would:
// 1. Load both snapshots from storage
// 2. Compare vulnerabilities and components
// 3. Compute policy diffs
// For now, return a structured response
var baseSummary = CreateSnapshotSummary(request.BaseDigest, "Block");
var targetSummary = CreateSnapshotSummary(request.TargetDigest, "Ship");
var response = new DeltaCompareResponseDto
{
Base = baseSummary,
Target = targetSummary,
Summary = new DeltaChangeSummaryDto
{
Added = 0,
Removed = 0,
Modified = 0,
Unchanged = 0,
NetVulnerabilityChange = 0,
NetComponentChange = 0,
SeverityChanges = new DeltaSeverityChangesDto(),
VerdictChanged = baseSummary.PolicyVerdict != targetSummary.PolicyVerdict,
RiskDirection = "unchanged"
},
Vulnerabilities = request.IncludeVulnerabilities ? [] : null,
Components = request.IncludeComponents ? [] : null,
PolicyDiff = request.IncludePolicyDiff
? new DeltaPolicyDiffDto
{
BaseVerdict = baseSummary.PolicyVerdict ?? "Unknown",
TargetVerdict = targetSummary.PolicyVerdict ?? "Unknown",
VerdictChanged = baseSummary.PolicyVerdict != targetSummary.PolicyVerdict,
BlockToShipCount = 0,
ShipToBlockCount = 0
}
: null,
GeneratedAt = _timeProvider.GetUtcNow(),
ComparisonId = comparisonId
};
return Task.FromResult(response);
}
public Task<QuickDiffSummaryDto> GetQuickDiffAsync(string baseDigest, string targetDigest, CancellationToken ct = default)
{
var summary = new QuickDiffSummaryDto
{
BaseDigest = baseDigest,
TargetDigest = targetDigest,
CanShip = true,
RiskDirection = "unchanged",
NetBlockingChange = 0,
CriticalAdded = 0,
CriticalRemoved = 0,
HighAdded = 0,
HighRemoved = 0,
Summary = "No material changes detected"
};
return Task.FromResult(summary);
}
public Task<DeltaCompareResponseDto?> GetComparisonAsync(string comparisonId, CancellationToken ct = default)
{
// In a full implementation, this would retrieve from cache/storage
return Task.FromResult<DeltaCompareResponseDto?>(null);
}
private DeltaSnapshotSummaryDto CreateSnapshotSummary(string digest, string verdict)
{
return new DeltaSnapshotSummaryDto
{
Digest = digest,
CreatedAt = _timeProvider.GetUtcNow(),
ComponentCount = 0,
VulnerabilityCount = 0,
SeverityCounts = new DeltaSeverityCountsDto(),
PolicyVerdict = verdict
};
}
private static string ComputeComparisonId(string baseDigest, string targetDigest)
{
var input = $"{baseDigest}|{targetDigest}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"cmp-{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,831 @@
// -----------------------------------------------------------------------------
// DeltaEvidenceEndpoints.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: HTTP endpoints for delta-specific evidence and proof bundles.
// -----------------------------------------------------------------------------
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for delta-specific evidence and proof bundles.
/// Per SPRINT_4200_0002_0006 T4.
/// </summary>
internal static class DeltaEvidenceEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
/// <summary>
/// Maps delta evidence endpoints.
/// </summary>
public static void MapDeltaEvidenceEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/delta/evidence")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var group = apiGroup.MapGroup(prefix)
.WithTags("DeltaEvidence");
// GET /v1/delta/evidence/{comparisonId} - Get evidence bundle for a comparison
group.MapGet("/{comparisonId}", HandleGetComparisonEvidenceAsync)
.WithName("scanner.delta.evidence.comparison")
.WithDescription("Get complete evidence bundle for a delta comparison.")
.Produces<DeltaEvidenceBundleDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/delta/evidence/{comparisonId}/finding/{findingId} - Get evidence for a specific finding change
group.MapGet("/{comparisonId}/finding/{findingId}", HandleGetFindingChangeEvidenceAsync)
.WithName("scanner.delta.evidence.finding")
.WithDescription("Get evidence for a specific finding's change in a delta.")
.Produces<DeltaFindingEvidenceDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/delta/evidence/{comparisonId}/proof-bundle - Get downloadable proof bundle
group.MapGet("/{comparisonId}/proof-bundle", HandleGetProofBundleAsync)
.WithName("scanner.delta.evidence.proof-bundle")
.WithDescription("Get downloadable proof bundle for audit/compliance.")
.Produces(StatusCodes.Status200OK, contentType: "application/zip")
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /v1/delta/evidence/{comparisonId}/attestations - Get attestation chain
group.MapGet("/{comparisonId}/attestations", HandleGetAttestationsAsync)
.WithName("scanner.delta.evidence.attestations")
.WithDescription("Get attestation chain for a delta comparison.")
.Produces<DeltaAttestationsDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleGetComparisonEvidenceAsync(
string comparisonId,
IDeltaEvidenceService evidenceService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evidenceService);
if (string.IsNullOrWhiteSpace(comparisonId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid comparison ID",
detail = "Comparison ID is required."
});
}
var evidence = await evidenceService.GetComparisonEvidenceAsync(comparisonId, cancellationToken);
if (evidence is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Comparison not found",
detail = $"Comparison with ID '{comparisonId}' was not found."
});
}
return Results.Ok(evidence);
}
private static async Task<IResult> HandleGetFindingChangeEvidenceAsync(
string comparisonId,
string findingId,
IDeltaEvidenceService evidenceService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evidenceService);
if (string.IsNullOrWhiteSpace(comparisonId) || string.IsNullOrWhiteSpace(findingId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid identifiers",
detail = "Both comparison ID and finding ID are required."
});
}
var evidence = await evidenceService.GetFindingEvidenceAsync(comparisonId, findingId, cancellationToken);
if (evidence is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Finding not found",
detail = $"Finding '{findingId}' not found in comparison '{comparisonId}'."
});
}
return Results.Ok(evidence);
}
private static async Task<IResult> HandleGetProofBundleAsync(
string comparisonId,
IDeltaEvidenceService evidenceService,
HttpContext context,
string? format = "zip",
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evidenceService);
if (string.IsNullOrWhiteSpace(comparisonId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid comparison ID",
detail = "Comparison ID is required."
});
}
var bundle = await evidenceService.GetProofBundleAsync(comparisonId, format ?? "zip", cancellationToken);
if (bundle is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Proof bundle not found",
detail = $"Proof bundle for comparison '{comparisonId}' was not found."
});
}
return Results.File(
bundle.Content,
bundle.ContentType,
bundle.FileName);
}
private static async Task<IResult> HandleGetAttestationsAsync(
string comparisonId,
IDeltaEvidenceService evidenceService,
HttpContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evidenceService);
if (string.IsNullOrWhiteSpace(comparisonId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid comparison ID",
detail = "Comparison ID is required."
});
}
var attestations = await evidenceService.GetAttestationsAsync(comparisonId, cancellationToken);
if (attestations is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Attestations not found",
detail = $"Attestations for comparison '{comparisonId}' were not found."
});
}
return Results.Ok(attestations);
}
}
#region DTOs
/// <summary>
/// Complete evidence bundle for a delta comparison.
/// </summary>
public sealed record DeltaEvidenceBundleDto
{
/// <summary>
/// Comparison ID.
/// </summary>
public required string ComparisonId { get; init; }
/// <summary>
/// Base snapshot evidence.
/// </summary>
public required DeltaSnapshotEvidenceDto Base { get; init; }
/// <summary>
/// Target snapshot evidence.
/// </summary>
public required DeltaSnapshotEvidenceDto Target { get; init; }
/// <summary>
/// Evidence for each changed finding.
/// </summary>
public required IReadOnlyList<DeltaFindingEvidenceDto> FindingChanges { get; init; }
/// <summary>
/// Policy evaluation evidence.
/// </summary>
public DeltaPolicyEvidenceDto? PolicyEvidence { get; init; }
/// <summary>
/// Attestation chain summary.
/// </summary>
public DeltaAttestationSummaryDto? AttestationSummary { get; init; }
/// <summary>
/// When this bundle was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// Evidence for a single snapshot.
/// </summary>
public sealed record DeltaSnapshotEvidenceDto
{
/// <summary>
/// Snapshot digest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Scan ID that produced this snapshot.
/// </summary>
public string? ScanId { get; init; }
/// <summary>
/// When the snapshot was created.
/// </summary>
public DateTimeOffset? CreatedAt { get; init; }
/// <summary>
/// SBOM attestation reference.
/// </summary>
public string? SbomAttestationRef { get; init; }
/// <summary>
/// Policy evaluation attestation reference.
/// </summary>
public string? PolicyAttestationRef { get; init; }
/// <summary>
/// Signature verification status.
/// </summary>
public string? SignatureStatus { get; init; }
/// <summary>
/// Rekor transparency log entry.
/// </summary>
public DeltaRekorEntryDto? RekorEntry { get; init; }
}
/// <summary>
/// Rekor transparency log entry.
/// </summary>
public sealed record DeltaRekorEntryDto
{
/// <summary>
/// Rekor log index.
/// </summary>
public long LogIndex { get; init; }
/// <summary>
/// Entry UUID.
/// </summary>
public required string Uuid { get; init; }
/// <summary>
/// Integrated time.
/// </summary>
public DateTimeOffset IntegratedTime { get; init; }
/// <summary>
/// Entry URL.
/// </summary>
public string? Url { get; init; }
}
/// <summary>
/// Evidence for a finding change.
/// </summary>
public sealed record DeltaFindingEvidenceDto
{
/// <summary>
/// Finding ID.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Vulnerability ID (CVE).
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Component PURL.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Type of change.
/// </summary>
public required string ChangeType { get; init; }
/// <summary>
/// Evidence for the change.
/// </summary>
public required DeltaChangeEvidenceDto ChangeEvidence { get; init; }
/// <summary>
/// Reachability evidence if applicable.
/// </summary>
public DeltaReachabilityEvidenceDto? ReachabilityEvidence { get; init; }
/// <summary>
/// VEX evidence if applicable.
/// </summary>
public DeltaVexEvidenceDto? VexEvidence { get; init; }
}
/// <summary>
/// Evidence for a specific change.
/// </summary>
public sealed record DeltaChangeEvidenceDto
{
/// <summary>
/// What changed.
/// </summary>
public required string Field { get; init; }
/// <summary>
/// Previous value.
/// </summary>
public string? PreviousValue { get; init; }
/// <summary>
/// Current value.
/// </summary>
public string? CurrentValue { get; init; }
/// <summary>
/// Source of the change (advisory, scan, vex, etc.).
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Reference to supporting document.
/// </summary>
public string? DocumentRef { get; init; }
}
/// <summary>
/// Reachability analysis evidence.
/// </summary>
public sealed record DeltaReachabilityEvidenceDto
{
/// <summary>
/// Reachability status.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Confidence score.
/// </summary>
public double Confidence { get; init; }
/// <summary>
/// Analysis method.
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Witness path ID if reachable.
/// </summary>
public string? WitnessId { get; init; }
/// <summary>
/// Call graph reference.
/// </summary>
public string? CallGraphRef { get; init; }
}
/// <summary>
/// VEX statement evidence.
/// </summary>
public sealed record DeltaVexEvidenceDto
{
/// <summary>
/// VEX status.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Justification.
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// Source of VEX statement.
/// </summary>
public required string Source { get; init; }
/// <summary>
/// VEX document reference.
/// </summary>
public string? DocumentRef { get; init; }
/// <summary>
/// When the VEX statement was issued.
/// </summary>
public DateTimeOffset? IssuedAt { get; init; }
}
/// <summary>
/// Policy evaluation evidence.
/// </summary>
public sealed record DeltaPolicyEvidenceDto
{
/// <summary>
/// Policy version used.
/// </summary>
public required string PolicyVersion { get; init; }
/// <summary>
/// Policy document hash.
/// </summary>
public required string PolicyHash { get; init; }
/// <summary>
/// Rules that were evaluated.
/// </summary>
public required IReadOnlyList<string> EvaluatedRules { get; init; }
/// <summary>
/// Base verdict.
/// </summary>
public required string BaseVerdict { get; init; }
/// <summary>
/// Target verdict.
/// </summary>
public required string TargetVerdict { get; init; }
/// <summary>
/// Policy decision attestation reference.
/// </summary>
public string? DecisionAttestationRef { get; init; }
}
/// <summary>
/// Attestation summary.
/// </summary>
public sealed record DeltaAttestationSummaryDto
{
/// <summary>
/// Total attestations in chain.
/// </summary>
public int TotalAttestations { get; init; }
/// <summary>
/// Verified attestations.
/// </summary>
public int VerifiedCount { get; init; }
/// <summary>
/// Chain is complete and verified.
/// </summary>
public bool ChainVerified { get; init; }
/// <summary>
/// Attestation types present.
/// </summary>
public required IReadOnlyList<string> AttestationTypes { get; init; }
}
/// <summary>
/// Full attestation chain.
/// </summary>
public sealed record DeltaAttestationsDto
{
/// <summary>
/// Comparison ID.
/// </summary>
public required string ComparisonId { get; init; }
/// <summary>
/// Attestations in chain order.
/// </summary>
public required IReadOnlyList<DeltaAttestationDto> Attestations { get; init; }
/// <summary>
/// Chain verification status.
/// </summary>
public required DeltaChainVerificationDto Verification { get; init; }
}
/// <summary>
/// Single attestation.
/// </summary>
public sealed record DeltaAttestationDto
{
/// <summary>
/// Attestation ID.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Attestation type (SBOM, policy, approval, etc.).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Predicate type URI.
/// </summary>
public required string PredicateType { get; init; }
/// <summary>
/// Subject digest.
/// </summary>
public required string SubjectDigest { get; init; }
/// <summary>
/// Signer identity.
/// </summary>
public string? Signer { get; init; }
/// <summary>
/// Signature verified.
/// </summary>
public bool SignatureVerified { get; init; }
/// <summary>
/// Timestamp.
/// </summary>
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Rekor entry if published.
/// </summary>
public DeltaRekorEntryDto? RekorEntry { get; init; }
}
/// <summary>
/// Chain verification result.
/// </summary>
public sealed record DeltaChainVerificationDto
{
/// <summary>
/// Chain is valid.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// All signatures verified.
/// </summary>
public bool AllSignaturesVerified { get; init; }
/// <summary>
/// Chain is complete (no gaps).
/// </summary>
public bool ChainComplete { get; init; }
/// <summary>
/// Verification errors if any.
/// </summary>
public IReadOnlyList<string>? Errors { get; init; }
/// <summary>
/// Verification warnings.
/// </summary>
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// Proof bundle for download.
/// </summary>
public sealed record ProofBundleDto
{
/// <summary>
/// Bundle content.
/// </summary>
public required byte[] Content { get; init; }
/// <summary>
/// Content type.
/// </summary>
public required string ContentType { get; init; }
/// <summary>
/// Suggested filename.
/// </summary>
public required string FileName { get; init; }
}
#endregion
#region Service Interface
/// <summary>
/// Service interface for delta evidence operations.
/// Per SPRINT_4200_0002_0006 T4.
/// </summary>
public interface IDeltaEvidenceService
{
/// <summary>
/// Gets complete evidence bundle for a comparison.
/// </summary>
Task<DeltaEvidenceBundleDto?> GetComparisonEvidenceAsync(string comparisonId, CancellationToken ct = default);
/// <summary>
/// Gets evidence for a specific finding change.
/// </summary>
Task<DeltaFindingEvidenceDto?> GetFindingEvidenceAsync(string comparisonId, string findingId, CancellationToken ct = default);
/// <summary>
/// Gets downloadable proof bundle.
/// </summary>
Task<ProofBundleDto?> GetProofBundleAsync(string comparisonId, string format, CancellationToken ct = default);
/// <summary>
/// Gets attestation chain.
/// </summary>
Task<DeltaAttestationsDto?> GetAttestationsAsync(string comparisonId, CancellationToken ct = default);
}
/// <summary>
/// Default implementation of delta evidence service.
/// </summary>
public sealed class DeltaEvidenceService : IDeltaEvidenceService
{
private readonly TimeProvider _timeProvider;
public DeltaEvidenceService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<DeltaEvidenceBundleDto?> GetComparisonEvidenceAsync(string comparisonId, CancellationToken ct = default)
{
// In a full implementation, this would retrieve actual evidence
var bundle = new DeltaEvidenceBundleDto
{
ComparisonId = comparisonId,
Base = new DeltaSnapshotEvidenceDto
{
Digest = $"sha256:base-{comparisonId[..8]}",
ScanId = $"scan-base-{comparisonId[..8]}",
CreatedAt = _timeProvider.GetUtcNow().AddDays(-1),
SignatureStatus = "verified",
SbomAttestationRef = $"att-sbom-base-{comparisonId[..8]}",
PolicyAttestationRef = $"att-policy-base-{comparisonId[..8]}"
},
Target = new DeltaSnapshotEvidenceDto
{
Digest = $"sha256:target-{comparisonId[..8]}",
ScanId = $"scan-target-{comparisonId[..8]}",
CreatedAt = _timeProvider.GetUtcNow(),
SignatureStatus = "verified",
SbomAttestationRef = $"att-sbom-target-{comparisonId[..8]}",
PolicyAttestationRef = $"att-policy-target-{comparisonId[..8]}"
},
FindingChanges = [],
PolicyEvidence = new DeltaPolicyEvidenceDto
{
PolicyVersion = "1.0.0",
PolicyHash = "sha256:policy123",
EvaluatedRules = ["critical-cve-block", "high-reachable-warn"],
BaseVerdict = "Block",
TargetVerdict = "Ship",
DecisionAttestationRef = $"att-decision-{comparisonId[..8]}"
},
AttestationSummary = new DeltaAttestationSummaryDto
{
TotalAttestations = 6,
VerifiedCount = 6,
ChainVerified = true,
AttestationTypes = ["sbom", "policy", "scan", "approval"]
},
GeneratedAt = _timeProvider.GetUtcNow()
};
return Task.FromResult<DeltaEvidenceBundleDto?>(bundle);
}
public Task<DeltaFindingEvidenceDto?> GetFindingEvidenceAsync(string comparisonId, string findingId, CancellationToken ct = default)
{
var evidence = new DeltaFindingEvidenceDto
{
FindingId = findingId,
VulnId = "CVE-2021-44228",
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
ChangeType = "Removed",
ChangeEvidence = new DeltaChangeEvidenceDto
{
Field = "version",
PreviousValue = "2.14.1",
CurrentValue = "2.17.1",
Source = "scan",
DocumentRef = $"sbom-{comparisonId[..8]}"
},
ReachabilityEvidence = new DeltaReachabilityEvidenceDto
{
Status = "not_reachable",
Confidence = 0.95,
Method = "static_analysis",
CallGraphRef = $"callgraph-{comparisonId[..8]}"
},
VexEvidence = new DeltaVexEvidenceDto
{
Status = "not_affected",
Justification = "vulnerable_code_not_in_execute_path",
Source = "vendor",
DocumentRef = "vex-apache-log4j-2024"
}
};
return Task.FromResult<DeltaFindingEvidenceDto?>(evidence);
}
public Task<ProofBundleDto?> GetProofBundleAsync(string comparisonId, string format, CancellationToken ct = default)
{
// In a full implementation, this would generate actual proof bundle
var jsonContent = System.Text.Json.JsonSerializer.Serialize(new
{
comparisonId,
generatedAt = _timeProvider.GetUtcNow(),
format,
note = "Proof bundle placeholder - full implementation would include attestations, signatures, and evidence"
});
var bundle = new ProofBundleDto
{
Content = System.Text.Encoding.UTF8.GetBytes(jsonContent),
ContentType = format == "json" ? "application/json" : "application/zip",
FileName = $"proof-bundle-{comparisonId}.{format}"
};
return Task.FromResult<ProofBundleDto?>(bundle);
}
public Task<DeltaAttestationsDto?> GetAttestationsAsync(string comparisonId, CancellationToken ct = default)
{
var attestations = new DeltaAttestationsDto
{
ComparisonId = comparisonId,
Attestations =
[
new DeltaAttestationDto
{
Id = $"att-sbom-{comparisonId[..8]}",
Type = "sbom",
PredicateType = "https://spdx.dev/Document",
SubjectDigest = $"sha256:target-{comparisonId[..8]}",
Signer = "scanner@stellaops.io",
SignatureVerified = true,
Timestamp = _timeProvider.GetUtcNow().AddMinutes(-30)
},
new DeltaAttestationDto
{
Id = $"att-policy-{comparisonId[..8]}",
Type = "policy",
PredicateType = "https://stellaops.io/attestations/policy/v1",
SubjectDigest = $"sha256:target-{comparisonId[..8]}",
Signer = "policy@stellaops.io",
SignatureVerified = true,
Timestamp = _timeProvider.GetUtcNow().AddMinutes(-25)
},
new DeltaAttestationDto
{
Id = $"att-comparison-{comparisonId[..8]}",
Type = "comparison",
PredicateType = "https://stellaops.io/attestations/comparison/v1",
SubjectDigest = $"sha256:comparison-{comparisonId[..8]}",
Signer = "scanner@stellaops.io",
SignatureVerified = true,
Timestamp = _timeProvider.GetUtcNow()
}
],
Verification = new DeltaChainVerificationDto
{
IsValid = true,
AllSignaturesVerified = true,
ChainComplete = true,
Warnings = []
}
};
return Task.FromResult<DeltaAttestationsDto?>(attestations);
}
}
#endregion

View File

@@ -0,0 +1,301 @@
// -----------------------------------------------------------------------------
// TriageStatusEndpoints.cs
// Sprint: SPRINT_4200_0001_0001_triage_rest_api
// Description: HTTP endpoints for triage status management.
// -----------------------------------------------------------------------------
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
/// <summary>
/// Endpoints for triage status management.
/// Per SPRINT_4200_0001_0001.
/// </summary>
internal static class TriageStatusEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
/// <summary>
/// Maps triage status endpoints.
/// </summary>
public static void MapTriageStatusEndpoints(this RouteGroupBuilder apiGroup)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var triageGroup = apiGroup.MapGroup("/triage")
.WithTags("Triage");
// GET /v1/triage/findings/{findingId} - Get triage status for a finding
triageGroup.MapGet("/findings/{findingId}", HandleGetFindingStatusAsync)
.WithName("scanner.triage.finding.status")
.WithDescription("Retrieves triage status for a specific finding.")
.Produces<FindingTriageStatusDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.TriageRead);
// POST /v1/triage/findings/{findingId}/status - Update triage status
triageGroup.MapPost("/findings/{findingId}/status", HandleUpdateStatusAsync)
.WithName("scanner.triage.finding.status.update")
.WithDescription("Updates triage status for a finding (lane change, decision).")
.Produces<UpdateTriageStatusResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.TriageWrite);
// POST /v1/triage/findings/{findingId}/vex - Submit VEX statement
triageGroup.MapPost("/findings/{findingId}/vex", HandleSubmitVexAsync)
.WithName("scanner.triage.finding.vex.submit")
.WithDescription("Submits a VEX statement for a finding.")
.Produces<SubmitVexStatementResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.TriageWrite);
// POST /v1/triage/query - Bulk query findings
triageGroup.MapPost("/query", HandleBulkQueryAsync)
.WithName("scanner.triage.query")
.WithDescription("Queries findings with filtering and pagination.")
.Produces<BulkTriageQueryResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.TriageRead);
// GET /v1/triage/summary - Get triage summary for an artifact
triageGroup.MapGet("/summary", HandleGetSummaryAsync)
.WithName("scanner.triage.summary")
.WithDescription("Returns triage summary statistics for an artifact.")
.Produces<TriageSummaryDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.TriageRead);
}
private static async Task<IResult> HandleGetFindingStatusAsync(
string findingId,
ITriageStatusService triageService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(triageService);
if (string.IsNullOrWhiteSpace(findingId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid finding ID",
detail = "Finding ID is required."
});
}
var status = await triageService.GetFindingStatusAsync(findingId, cancellationToken);
if (status is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Finding not found",
detail = $"Finding with ID '{findingId}' was not found."
});
}
return Results.Ok(status);
}
private static async Task<IResult> HandleUpdateStatusAsync(
string findingId,
UpdateTriageStatusRequestDto request,
ITriageStatusService triageService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(triageService);
if (string.IsNullOrWhiteSpace(findingId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid finding ID",
detail = "Finding ID is required."
});
}
// Get actor from context or request
var actor = request.Actor ?? context.User?.Identity?.Name ?? "anonymous";
var result = await triageService.UpdateStatusAsync(findingId, request, actor, cancellationToken);
if (result is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Finding not found",
detail = $"Finding with ID '{findingId}' was not found."
});
}
return Results.Ok(result);
}
private static async Task<IResult> HandleSubmitVexAsync(
string findingId,
SubmitVexStatementRequestDto request,
ITriageStatusService triageService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(triageService);
if (string.IsNullOrWhiteSpace(findingId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid finding ID",
detail = "Finding ID is required."
});
}
if (string.IsNullOrWhiteSpace(request.Status))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid VEX status",
detail = "VEX status is required."
});
}
// Validate status is a known value
var validStatuses = new[] { "Affected", "NotAffected", "UnderInvestigation", "Unknown" };
if (!validStatuses.Contains(request.Status, StringComparer.OrdinalIgnoreCase))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid VEX status",
detail = $"VEX status must be one of: {string.Join(", ", validStatuses)}"
});
}
// For NotAffected, justification should be provided
if (request.Status.Equals("NotAffected", StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(request.Justification))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Justification required",
detail = "Justification is required when status is NotAffected."
});
}
var actor = request.IssuedBy ?? context.User?.Identity?.Name ?? "anonymous";
var result = await triageService.SubmitVexStatementAsync(findingId, request, actor, cancellationToken);
if (result is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Finding not found",
detail = $"Finding with ID '{findingId}' was not found."
});
}
return Results.Ok(result);
}
private static async Task<IResult> HandleBulkQueryAsync(
BulkTriageQueryRequestDto request,
ITriageStatusService triageService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(triageService);
// Apply reasonable defaults
var limit = Math.Min(request.Limit ?? 100, 1000);
var result = await triageService.QueryFindingsAsync(request, limit, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> HandleGetSummaryAsync(
string artifactDigest,
ITriageStatusService triageService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(triageService);
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid artifact digest",
detail = "Artifact digest is required."
});
}
var summary = await triageService.GetSummaryAsync(artifactDigest, cancellationToken);
return Results.Ok(summary);
}
}
/// <summary>
/// Service interface for triage status operations.
/// Per SPRINT_4200_0001_0001.
/// </summary>
public interface ITriageStatusService
{
/// <summary>
/// Gets triage status for a finding.
/// </summary>
Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default);
/// <summary>
/// Updates triage status for a finding.
/// </summary>
Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
string findingId,
UpdateTriageStatusRequestDto request,
string actor,
CancellationToken ct = default);
/// <summary>
/// Submits a VEX statement for a finding.
/// </summary>
Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
string findingId,
SubmitVexStatementRequestDto request,
string actor,
CancellationToken ct = default);
/// <summary>
/// Queries findings with filtering.
/// </summary>
Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
BulkTriageQueryRequestDto request,
int limit,
CancellationToken ct = default);
/// <summary>
/// Gets triage summary for an artifact.
/// </summary>
Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default);
}