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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user