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,228 @@
// -----------------------------------------------------------------------------
// BaselineContracts.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: DTOs for baseline selection API.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// A recommended baseline for comparison.
/// </summary>
public sealed record BaselineRecommendationDto
{
/// <summary>
/// Unique identifier for this recommendation.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Type of baseline: last-green, previous-release, main-branch, parent-commit, custom.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Human-readable label.
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Artifact digest for this baseline.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// When this baseline was scanned.
/// </summary>
public DateTimeOffset? Timestamp { get; init; }
/// <summary>
/// Why this baseline was recommended.
/// </summary>
public required string Rationale { get; init; }
/// <summary>
/// Verdict status: allowed, blocked, warn, unknown.
/// </summary>
public string? VerdictStatus { get; init; }
/// <summary>
/// Policy version used for the baseline verdict.
/// </summary>
public string? PolicyVersion { get; init; }
/// <summary>
/// Whether this is the default/recommended baseline.
/// </summary>
public bool IsDefault { get; init; }
}
/// <summary>
/// Response containing baseline recommendations.
/// </summary>
public sealed record BaselineRecommendationsResponseDto
{
/// <summary>
/// The artifact being compared.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// List of recommended baselines, ordered by relevance.
/// </summary>
public required IReadOnlyList<BaselineRecommendationDto> Recommendations { get; init; }
/// <summary>
/// When recommendations were generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// Detailed rationale for a baseline selection.
/// </summary>
public sealed record BaselineRationaleResponseDto
{
/// <summary>
/// Base artifact digest.
/// </summary>
public required string BaseDigest { get; init; }
/// <summary>
/// Head/target artifact digest.
/// </summary>
public required string HeadDigest { get; init; }
/// <summary>
/// How this baseline was selected: last-green, previous-release, manual.
/// </summary>
public required string SelectionType { get; init; }
/// <summary>
/// Short rationale text.
/// </summary>
public required string Rationale { get; init; }
/// <summary>
/// Detailed explanation for auditors.
/// </summary>
public required string DetailedExplanation { get; init; }
/// <summary>
/// Criteria used for selection.
/// </summary>
public IReadOnlyList<string>? SelectionCriteria { get; init; }
/// <summary>
/// When the base was scanned.
/// </summary>
public DateTimeOffset? BaseTimestamp { get; init; }
/// <summary>
/// When the head was scanned.
/// </summary>
public DateTimeOffset? HeadTimestamp { get; init; }
}
/// <summary>
/// Actionable recommendation for remediation.
/// </summary>
public sealed record ActionableDto
{
/// <summary>
/// Unique identifier for this actionable.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Type: upgrade, patch, vex, config, investigate.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Priority: critical, high, medium, low.
/// </summary>
public required string Priority { get; init; }
/// <summary>
/// Short title.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Detailed description.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Affected component PURL.
/// </summary>
public string? Component { get; init; }
/// <summary>
/// Current version.
/// </summary>
public string? CurrentVersion { get; init; }
/// <summary>
/// Target version to upgrade to.
/// </summary>
public string? TargetVersion { get; init; }
/// <summary>
/// Related CVE IDs.
/// </summary>
public IReadOnlyList<string>? CveIds { get; init; }
/// <summary>
/// Estimated effort: trivial, low, medium, high.
/// </summary>
public string? EstimatedEffort { get; init; }
/// <summary>
/// Supporting evidence references.
/// </summary>
public ActionableEvidenceDto? Evidence { get; init; }
}
/// <summary>
/// Evidence supporting an actionable.
/// </summary>
public sealed record ActionableEvidenceDto
{
/// <summary>
/// Witness path ID for reachability evidence.
/// </summary>
public string? WitnessId { get; init; }
/// <summary>
/// VEX document ID.
/// </summary>
public string? VexDocumentId { get; init; }
/// <summary>
/// Policy rule ID that triggered this.
/// </summary>
public string? PolicyRuleId { get; init; }
}
/// <summary>
/// Response containing actionables for a delta.
/// </summary>
public sealed record ActionablesResponseDto
{
/// <summary>
/// Delta ID these actionables are for.
/// </summary>
public required string DeltaId { get; init; }
/// <summary>
/// List of actionables, sorted by priority.
/// </summary>
public required IReadOnlyList<ActionableDto> Actionables { get; init; }
/// <summary>
/// When actionables were generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
}

View File

@@ -0,0 +1,440 @@
// -----------------------------------------------------------------------------
// DeltaCompareContracts.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: DTOs for delta/compare view backend API.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Request to compare two scan snapshots.
/// </summary>
public sealed record DeltaCompareRequestDto
{
/// <summary>
/// Base snapshot digest (the "before" state).
/// </summary>
public required string BaseDigest { get; init; }
/// <summary>
/// Target snapshot digest (the "after" state).
/// </summary>
public required string TargetDigest { get; init; }
/// <summary>
/// Optional filter for change types.
/// </summary>
public IReadOnlyList<string>? ChangeTypes { get; init; }
/// <summary>
/// Optional filter for severity levels.
/// </summary>
public IReadOnlyList<string>? Severities { get; init; }
/// <summary>
/// Include findings that are unchanged.
/// </summary>
public bool IncludeUnchanged { get; init; }
/// <summary>
/// Include component-level diff.
/// </summary>
public bool IncludeComponents { get; init; } = true;
/// <summary>
/// Include vulnerability-level diff.
/// </summary>
public bool IncludeVulnerabilities { get; init; } = true;
/// <summary>
/// Include policy verdict diff.
/// </summary>
public bool IncludePolicyDiff { get; init; } = true;
}
/// <summary>
/// Response containing the delta comparison results.
/// </summary>
public sealed record DeltaCompareResponseDto
{
/// <summary>
/// Base snapshot summary.
/// </summary>
public required DeltaSnapshotSummaryDto Base { get; init; }
/// <summary>
/// Target snapshot summary.
/// </summary>
public required DeltaSnapshotSummaryDto Target { get; init; }
/// <summary>
/// Summary of changes.
/// </summary>
public required DeltaChangeSummaryDto Summary { get; init; }
/// <summary>
/// Vulnerability changes.
/// </summary>
public IReadOnlyList<DeltaVulnerabilityDto>? Vulnerabilities { get; init; }
/// <summary>
/// Component changes.
/// </summary>
public IReadOnlyList<DeltaComponentDto>? Components { get; init; }
/// <summary>
/// Policy verdict changes.
/// </summary>
public DeltaPolicyDiffDto? PolicyDiff { get; init; }
/// <summary>
/// When this comparison was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Deterministic comparison ID for caching.
/// </summary>
public required string ComparisonId { get; init; }
}
/// <summary>
/// Summary of a scan snapshot.
/// </summary>
public sealed record DeltaSnapshotSummaryDto
{
/// <summary>
/// Digest of the snapshot.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// When the snapshot was created.
/// </summary>
public DateTimeOffset? CreatedAt { get; init; }
/// <summary>
/// Total component count.
/// </summary>
public int ComponentCount { get; init; }
/// <summary>
/// Total vulnerability count.
/// </summary>
public int VulnerabilityCount { get; init; }
/// <summary>
/// Count by severity.
/// </summary>
public required DeltaSeverityCountsDto SeverityCounts { get; init; }
/// <summary>
/// Overall policy verdict.
/// </summary>
public string? PolicyVerdict { get; init; }
}
/// <summary>
/// Counts by severity level.
/// </summary>
public sealed record DeltaSeverityCountsDto
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Unknown { get; init; }
}
/// <summary>
/// Summary of changes between snapshots.
/// </summary>
public sealed record DeltaChangeSummaryDto
{
/// <summary>
/// Number of added findings.
/// </summary>
public int Added { get; init; }
/// <summary>
/// Number of removed findings.
/// </summary>
public int Removed { get; init; }
/// <summary>
/// Number of modified findings.
/// </summary>
public int Modified { get; init; }
/// <summary>
/// Number of unchanged findings.
/// </summary>
public int Unchanged { get; init; }
/// <summary>
/// Net change in vulnerability count.
/// </summary>
public int NetVulnerabilityChange { get; init; }
/// <summary>
/// Net change in component count.
/// </summary>
public int NetComponentChange { get; init; }
/// <summary>
/// Severity changes summary.
/// </summary>
public required DeltaSeverityChangesDto SeverityChanges { get; init; }
/// <summary>
/// Whether the policy verdict changed.
/// </summary>
public bool VerdictChanged { get; init; }
/// <summary>
/// Direction of risk change (improved, degraded, unchanged).
/// </summary>
public required string RiskDirection { get; init; }
}
/// <summary>
/// Changes in severity counts.
/// </summary>
public sealed record DeltaSeverityChangesDto
{
public int CriticalAdded { get; init; }
public int CriticalRemoved { get; init; }
public int HighAdded { get; init; }
public int HighRemoved { get; init; }
public int MediumAdded { get; init; }
public int MediumRemoved { get; init; }
public int LowAdded { get; init; }
public int LowRemoved { get; init; }
}
/// <summary>
/// Individual vulnerability change.
/// </summary>
public sealed record DeltaVulnerabilityDto
{
/// <summary>
/// Vulnerability ID (CVE).
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Package URL of affected component.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Type of change (Added, Removed, Modified, Unchanged).
/// </summary>
public required string ChangeType { get; init; }
/// <summary>
/// Severity level.
/// </summary>
public required string Severity { get; init; }
/// <summary>
/// Previous severity if changed.
/// </summary>
public string? PreviousSeverity { get; init; }
/// <summary>
/// VEX status.
/// </summary>
public string? VexStatus { get; init; }
/// <summary>
/// Previous VEX status if changed.
/// </summary>
public string? PreviousVexStatus { get; init; }
/// <summary>
/// Reachability status.
/// </summary>
public string? Reachability { get; init; }
/// <summary>
/// Previous reachability if changed.
/// </summary>
public string? PreviousReachability { get; init; }
/// <summary>
/// Policy verdict for this finding.
/// </summary>
public string? Verdict { get; init; }
/// <summary>
/// Previous verdict if changed.
/// </summary>
public string? PreviousVerdict { get; init; }
/// <summary>
/// Fixed version if available.
/// </summary>
public string? FixedVersion { get; init; }
/// <summary>
/// Detailed field-level changes.
/// </summary>
public IReadOnlyList<DeltaFieldChangeDto>? FieldChanges { get; init; }
}
/// <summary>
/// Individual component change.
/// </summary>
public sealed record DeltaComponentDto
{
/// <summary>
/// Package URL.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Type of change (Added, Removed, VersionChanged, Unchanged).
/// </summary>
public required string ChangeType { get; init; }
/// <summary>
/// Previous version if changed.
/// </summary>
public string? PreviousVersion { get; init; }
/// <summary>
/// Current version.
/// </summary>
public string? CurrentVersion { get; init; }
/// <summary>
/// Vulnerabilities in base snapshot.
/// </summary>
public int VulnerabilitiesInBase { get; init; }
/// <summary>
/// Vulnerabilities in target snapshot.
/// </summary>
public int VulnerabilitiesInTarget { get; init; }
/// <summary>
/// License.
/// </summary>
public string? License { get; init; }
}
/// <summary>
/// Field-level change detail.
/// </summary>
public sealed record DeltaFieldChangeDto
{
/// <summary>
/// Field name.
/// </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>
/// Policy diff between snapshots.
/// </summary>
public sealed record DeltaPolicyDiffDto
{
/// <summary>
/// Base verdict.
/// </summary>
public required string BaseVerdict { get; init; }
/// <summary>
/// Target verdict.
/// </summary>
public required string TargetVerdict { get; init; }
/// <summary>
/// Whether verdict changed.
/// </summary>
public bool VerdictChanged { get; init; }
/// <summary>
/// Findings that changed from Block to Ship.
/// </summary>
public int BlockToShipCount { get; init; }
/// <summary>
/// Findings that changed from Ship to Block.
/// </summary>
public int ShipToBlockCount { get; init; }
/// <summary>
/// Counterfactuals for blocking findings.
/// </summary>
public IReadOnlyList<string>? WouldPassIf { get; init; }
}
/// <summary>
/// Quick diff summary for header display (Can I Ship?).
/// </summary>
public sealed record QuickDiffSummaryDto
{
/// <summary>
/// Base digest.
/// </summary>
public required string BaseDigest { get; init; }
/// <summary>
/// Target digest.
/// </summary>
public required string TargetDigest { get; init; }
/// <summary>
/// Can the target ship? (true if policy verdict is Pass).
/// </summary>
public bool CanShip { get; init; }
/// <summary>
/// Risk direction (improved, degraded, unchanged).
/// </summary>
public required string RiskDirection { get; init; }
/// <summary>
/// Net change in blocking findings.
/// </summary>
public int NetBlockingChange { get; init; }
/// <summary>
/// Critical vulns added.
/// </summary>
public int CriticalAdded { get; init; }
/// <summary>
/// Critical vulns removed.
/// </summary>
public int CriticalRemoved { get; init; }
/// <summary>
/// High vulns added.
/// </summary>
public int HighAdded { get; init; }
/// <summary>
/// High vulns removed.
/// </summary>
public int HighRemoved { get; init; }
/// <summary>
/// Summary message.
/// </summary>
public required string Summary { get; init; }
}

View File

@@ -67,6 +67,15 @@ public sealed record ReportDocumentDto
[JsonPropertyOrder(9)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<LinksetSummaryDto>? Linksets { get; init; }
/// <summary>
/// Unknown budget status for this scan.
/// Sprint: SPRINT_4300_0002_0001 (BUDGET-017)
/// </summary>
[JsonPropertyName("unknownBudget")]
[JsonPropertyOrder(10)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public UnknownBudgetSectionDto? UnknownBudget { get; init; }
}
public sealed record ReportPolicyDto
@@ -102,3 +111,112 @@ public sealed record ReportSummaryDto
[JsonPropertyOrder(4)]
public int Quieted { get; init; }
}
/// <summary>
/// Unknown budget status section for scan reports.
/// Sprint: SPRINT_4300_0002_0001 (BUDGET-017)
/// </summary>
public sealed record UnknownBudgetSectionDto
{
/// <summary>
/// Environment against which budget was evaluated.
/// </summary>
[JsonPropertyName("environment")]
[JsonPropertyOrder(0)]
public string Environment { get; init; } = string.Empty;
/// <summary>
/// Whether the scan is within the budget limits.
/// </summary>
[JsonPropertyName("withinBudget")]
[JsonPropertyOrder(1)]
public bool WithinBudget { get; init; }
/// <summary>
/// Recommended action: "pass", "warn", or "block".
/// </summary>
[JsonPropertyName("action")]
[JsonPropertyOrder(2)]
public string Action { get; init; } = "pass";
/// <summary>
/// Total unknown count in this scan.
/// </summary>
[JsonPropertyName("totalUnknowns")]
[JsonPropertyOrder(3)]
public int TotalUnknowns { get; init; }
/// <summary>
/// Configured total limit for the environment.
/// </summary>
[JsonPropertyName("totalLimit")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? TotalLimit { get; init; }
/// <summary>
/// Percentage of budget used (0-100).
/// </summary>
[JsonPropertyName("percentageUsed")]
[JsonPropertyOrder(5)]
public decimal PercentageUsed { get; init; }
/// <summary>
/// Budget violations by reason code.
/// </summary>
[JsonPropertyName("violations")]
[JsonPropertyOrder(6)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<UnknownBudgetViolationDto>? Violations { get; init; }
/// <summary>
/// Message describing budget status.
/// </summary>
[JsonPropertyName("message")]
[JsonPropertyOrder(7)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; init; }
/// <summary>
/// Breakdown of unknowns by reason code.
/// </summary>
[JsonPropertyName("byReasonCode")]
[JsonPropertyOrder(8)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyDictionary<string, int>? ByReasonCode { get; init; }
}
/// <summary>
/// Details of a specific budget violation.
/// Sprint: SPRINT_4300_0002_0001 (BUDGET-017)
/// </summary>
public sealed record UnknownBudgetViolationDto
{
/// <summary>
/// Reason code that exceeded its limit.
/// </summary>
[JsonPropertyName("reasonCode")]
[JsonPropertyOrder(0)]
public string ReasonCode { get; init; } = string.Empty;
/// <summary>
/// Short code for the reason (e.g., "U-RCH").
/// </summary>
[JsonPropertyName("shortCode")]
[JsonPropertyOrder(1)]
public string ShortCode { get; init; } = string.Empty;
/// <summary>
/// Actual count of unknowns for this reason.
/// </summary>
[JsonPropertyName("count")]
[JsonPropertyOrder(2)]
public int Count { get; init; }
/// <summary>
/// Configured limit for this reason.
/// </summary>
[JsonPropertyName("limit")]
[JsonPropertyOrder(3)]
public int Limit { get; init; }
}

View File

@@ -0,0 +1,464 @@
// -----------------------------------------------------------------------------
// TriageContracts.cs
// Sprint: SPRINT_4200_0001_0001_triage_rest_api
// Description: DTOs for triage status REST API.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Response DTO for finding triage status.
/// </summary>
public sealed record FindingTriageStatusDto
{
/// <summary>
/// Unique finding identifier.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Current triage lane.
/// </summary>
public required string Lane { get; init; }
/// <summary>
/// Final verdict (Ship/Block/Exception).
/// </summary>
public required string Verdict { get; init; }
/// <summary>
/// Human-readable reason for the current status.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// VEX status if applicable.
/// </summary>
public TriageVexStatusDto? VexStatus { get; init; }
/// <summary>
/// Reachability determination if applicable.
/// </summary>
public TriageReachabilityDto? Reachability { get; init; }
/// <summary>
/// Risk score information.
/// </summary>
public TriageRiskScoreDto? RiskScore { get; init; }
/// <summary>
/// Policy counterfactuals - what would flip this to Ship.
/// </summary>
public IReadOnlyList<string>? WouldPassIf { get; init; }
/// <summary>
/// Attached evidence artifacts.
/// </summary>
public IReadOnlyList<TriageEvidenceDto>? Evidence { get; init; }
/// <summary>
/// When this status was last computed.
/// </summary>
public DateTimeOffset? ComputedAt { get; init; }
/// <summary>
/// Link to proof bundle for this finding.
/// </summary>
public string? ProofBundleUri { get; init; }
}
/// <summary>
/// VEX status DTO.
/// </summary>
public sealed record TriageVexStatusDto
{
/// <summary>
/// Status value (Affected, NotAffected, UnderInvestigation, Unknown).
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Justification category for NotAffected status.
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// Impact statement explaining the decision.
/// </summary>
public string? ImpactStatement { get; init; }
/// <summary>
/// Who issued the VEX statement.
/// </summary>
public string? IssuedBy { get; init; }
/// <summary>
/// When the VEX statement was issued.
/// </summary>
public DateTimeOffset? IssuedAt { get; init; }
/// <summary>
/// Reference to the VEX document.
/// </summary>
public string? VexDocumentRef { get; init; }
}
/// <summary>
/// Reachability determination DTO.
/// </summary>
public sealed record TriageReachabilityDto
{
/// <summary>
/// Status (Yes, No, Unknown).
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Confidence level (0-1).
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// Source of the reachability determination.
/// </summary>
public string? Source { get; init; }
/// <summary>
/// Entry points if reachable.
/// </summary>
public IReadOnlyList<string>? EntryPoints { get; init; }
/// <summary>
/// When the analysis was performed.
/// </summary>
public DateTimeOffset? AnalyzedAt { get; init; }
}
/// <summary>
/// Risk score DTO.
/// </summary>
public sealed record TriageRiskScoreDto
{
/// <summary>
/// Computed risk score (0-10).
/// </summary>
public double Score { get; init; }
/// <summary>
/// Critical severity count.
/// </summary>
public int CriticalCount { get; init; }
/// <summary>
/// High severity count.
/// </summary>
public int HighCount { get; init; }
/// <summary>
/// Medium severity count.
/// </summary>
public int MediumCount { get; init; }
/// <summary>
/// Low severity count.
/// </summary>
public int LowCount { get; init; }
/// <summary>
/// EPSS probability if available.
/// </summary>
public double? EpssScore { get; init; }
/// <summary>
/// EPSS percentile if available.
/// </summary>
public double? EpssPercentile { get; init; }
}
/// <summary>
/// Evidence artifact DTO.
/// </summary>
public sealed record TriageEvidenceDto
{
/// <summary>
/// Evidence type.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// URI to retrieve the evidence.
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Content digest (sha256).
/// </summary>
public string? Digest { get; init; }
/// <summary>
/// When this evidence was created.
/// </summary>
public DateTimeOffset? CreatedAt { get; init; }
}
/// <summary>
/// Request to update finding triage status.
/// </summary>
public sealed record UpdateTriageStatusRequestDto
{
/// <summary>
/// New lane to move the finding to.
/// </summary>
public string? Lane { get; init; }
/// <summary>
/// Decision kind (MuteReach, MuteVex, Ack, Exception).
/// </summary>
public string? DecisionKind { get; init; }
/// <summary>
/// Reason/justification for the change.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Exception details if DecisionKind is Exception.
/// </summary>
public TriageExceptionRequestDto? Exception { get; init; }
/// <summary>
/// Actor making the change.
/// </summary>
public string? Actor { get; init; }
}
/// <summary>
/// Exception request details.
/// </summary>
public sealed record TriageExceptionRequestDto
{
/// <summary>
/// When the exception expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Approver identifier.
/// </summary>
public string? ApprovedBy { get; init; }
/// <summary>
/// Ticket/reference for the exception.
/// </summary>
public string? TicketRef { get; init; }
/// <summary>
/// Compensating controls applied.
/// </summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
}
/// <summary>
/// Response after updating triage status.
/// </summary>
public sealed record UpdateTriageStatusResponseDto
{
/// <summary>
/// The finding ID.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Previous lane.
/// </summary>
public required string PreviousLane { get; init; }
/// <summary>
/// New lane.
/// </summary>
public required string NewLane { get; init; }
/// <summary>
/// Previous verdict.
/// </summary>
public required string PreviousVerdict { get; init; }
/// <summary>
/// New verdict.
/// </summary>
public required string NewVerdict { get; init; }
/// <summary>
/// Snapshot ID for audit trail.
/// </summary>
public string? SnapshotId { get; init; }
/// <summary>
/// When the change was applied.
/// </summary>
public required DateTimeOffset AppliedAt { get; init; }
}
/// <summary>
/// Request to submit a VEX statement for a finding.
/// </summary>
public sealed record SubmitVexStatementRequestDto
{
/// <summary>
/// VEX status (Affected, NotAffected, UnderInvestigation, Unknown).
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Justification category for NotAffected.
/// Per OpenVEX: component_not_present, vulnerable_code_not_present,
/// vulnerable_code_not_in_execute_path, inline_mitigations_already_exist.
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// Impact statement.
/// </summary>
public string? ImpactStatement { get; init; }
/// <summary>
/// Action statement for remediation.
/// </summary>
public string? ActionStatement { get; init; }
/// <summary>
/// When the VEX statement becomes effective.
/// </summary>
public DateTimeOffset? EffectiveAt { get; init; }
/// <summary>
/// Actor submitting the VEX statement.
/// </summary>
public string? IssuedBy { get; init; }
}
/// <summary>
/// Response after submitting VEX statement.
/// </summary>
public sealed record SubmitVexStatementResponseDto
{
/// <summary>
/// VEX statement ID.
/// </summary>
public required string VexStatementId { get; init; }
/// <summary>
/// Finding ID this applies to.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// The applied status.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Whether this changed the triage verdict.
/// </summary>
public bool VerdictChanged { get; init; }
/// <summary>
/// New verdict if changed.
/// </summary>
public string? NewVerdict { get; init; }
/// <summary>
/// When the statement was recorded.
/// </summary>
public required DateTimeOffset RecordedAt { get; init; }
}
/// <summary>
/// Bulk triage query request.
/// </summary>
public sealed record BulkTriageQueryRequestDto
{
/// <summary>
/// Artifact digest (image or SBOM).
/// </summary>
public string? ArtifactDigest { get; init; }
/// <summary>
/// Filter by lane.
/// </summary>
public string? Lane { get; init; }
/// <summary>
/// Filter by verdict.
/// </summary>
public string? Verdict { get; init; }
/// <summary>
/// Filter by CVE ID prefix.
/// </summary>
public string? CvePrefix { get; init; }
/// <summary>
/// Maximum results.
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// Pagination cursor.
/// </summary>
public string? Cursor { get; init; }
}
/// <summary>
/// Bulk triage query response.
/// </summary>
public sealed record BulkTriageQueryResponseDto
{
/// <summary>
/// The findings matching the query.
/// </summary>
public required IReadOnlyList<FindingTriageStatusDto> Findings { get; init; }
/// <summary>
/// Total count matching the query.
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// Next cursor for pagination.
/// </summary>
public string? NextCursor { get; init; }
/// <summary>
/// Summary statistics.
/// </summary>
public TriageSummaryDto? Summary { get; init; }
}
/// <summary>
/// Summary statistics for triage.
/// </summary>
public sealed record TriageSummaryDto
{
/// <summary>
/// Count by lane.
/// </summary>
public required IDictionary<string, int> ByLane { get; init; }
/// <summary>
/// Count by verdict.
/// </summary>
public required IDictionary<string, int> ByVerdict { get; init; }
/// <summary>
/// Findings that can ship.
/// </summary>
public int CanShipCount { get; init; }
/// <summary>
/// Findings that block shipment.
/// </summary>
public int BlockingCount { get; init; }
}

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

View File

@@ -0,0 +1,359 @@
// -----------------------------------------------------------------------------
// TriageStatusService.cs
// Sprint: SPRINT_4200_0001_0001_triage_rest_api
// Description: Service implementation for triage status operations.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Counterfactuals;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Endpoints.Triage;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Default implementation of triage status service.
/// </summary>
public sealed class TriageStatusService : ITriageStatusService
{
private readonly ILogger<TriageStatusService> _logger;
private readonly ITriageQueryService _queryService;
private readonly ICounterfactualEngine? _counterfactualEngine;
private readonly TimeProvider _timeProvider;
public TriageStatusService(
ILogger<TriageStatusService> logger,
ITriageQueryService queryService,
TimeProvider timeProvider,
ICounterfactualEngine? counterfactualEngine = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_counterfactualEngine = counterfactualEngine;
}
public async Task<FindingTriageStatusDto?> GetFindingStatusAsync(
string findingId,
CancellationToken ct = default)
{
_logger.LogDebug("Getting triage status for finding {FindingId}", findingId);
var finding = await _queryService.GetFindingAsync(findingId, ct);
if (finding is null)
{
return null;
}
return MapToDto(finding);
}
public async Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
string findingId,
UpdateTriageStatusRequestDto request,
string actor,
CancellationToken ct = default)
{
_logger.LogDebug("Updating triage status for finding {FindingId} by {Actor}", findingId, actor);
var finding = await _queryService.GetFindingAsync(findingId, ct);
if (finding is null)
{
return null;
}
var previousLane = GetCurrentLane(finding);
var previousVerdict = GetCurrentVerdict(finding);
// In a full implementation, this would:
// 1. Create a new TriageDecision
// 2. Update the finding lane
// 3. Create a snapshot for audit
var newLane = !string.IsNullOrWhiteSpace(request.Lane) ? request.Lane : previousLane;
var newVerdict = ComputeVerdict(newLane, request.DecisionKind);
_logger.LogInformation(
"Triage status updated: Finding={FindingId}, Lane={PrevLane}->{NewLane}, Verdict={PrevVerdict}->{NewVerdict}",
findingId, previousLane, newLane, previousVerdict, newVerdict);
return new UpdateTriageStatusResponseDto
{
FindingId = findingId,
PreviousLane = previousLane,
NewLane = newLane,
PreviousVerdict = previousVerdict,
NewVerdict = newVerdict,
SnapshotId = $"snap-{Guid.NewGuid():N}",
AppliedAt = _timeProvider.GetUtcNow()
};
}
public async Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
string findingId,
SubmitVexStatementRequestDto request,
string actor,
CancellationToken ct = default)
{
_logger.LogDebug("Submitting VEX statement for finding {FindingId} by {Actor}", findingId, actor);
var finding = await _queryService.GetFindingAsync(findingId, ct);
if (finding is null)
{
return null;
}
var previousVerdict = GetCurrentVerdict(finding);
var vexStatementId = $"vex-{Guid.NewGuid():N}";
// Determine if verdict changes based on VEX status
var verdictChanged = false;
string? newVerdict = null;
if (request.Status.Equals("NotAffected", StringComparison.OrdinalIgnoreCase))
{
verdictChanged = previousVerdict != "Ship";
newVerdict = "Ship";
}
_logger.LogInformation(
"VEX statement submitted: Finding={FindingId}, Status={Status}, VerdictChanged={Changed}",
findingId, request.Status, verdictChanged);
return new SubmitVexStatementResponseDto
{
VexStatementId = vexStatementId,
FindingId = findingId,
Status = request.Status,
VerdictChanged = verdictChanged,
NewVerdict = newVerdict,
RecordedAt = _timeProvider.GetUtcNow()
};
}
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
BulkTriageQueryRequestDto request,
int limit,
CancellationToken ct = default)
{
_logger.LogDebug("Querying findings with limit {Limit}", limit);
// In a full implementation, this would query the database
// For now, return empty results
var response = new BulkTriageQueryResponseDto
{
Findings = [],
TotalCount = 0,
NextCursor = null,
Summary = new TriageSummaryDto
{
ByLane = new Dictionary<string, int>(),
ByVerdict = new Dictionary<string, int>(),
CanShipCount = 0,
BlockingCount = 0
}
};
return Task.FromResult(response);
}
public Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default)
{
_logger.LogDebug("Getting triage summary for artifact {ArtifactDigest}", artifactDigest);
// In a full implementation, this would aggregate data from the database
var summary = new TriageSummaryDto
{
ByLane = new Dictionary<string, int>
{
["Active"] = 0,
["Blocked"] = 0,
["NeedsException"] = 0,
["MutedReach"] = 0,
["MutedVex"] = 0,
["Compensated"] = 0
},
ByVerdict = new Dictionary<string, int>
{
["Ship"] = 0,
["Block"] = 0,
["Exception"] = 0
},
CanShipCount = 0,
BlockingCount = 0
};
return Task.FromResult(summary);
}
private FindingTriageStatusDto MapToDto(TriageFinding finding)
{
var lane = GetCurrentLane(finding);
var verdict = GetCurrentVerdict(finding);
TriageVexStatusDto? vexStatus = null;
var latestVex = finding.EffectiveVexRecords
.OrderByDescending(v => v.EffectiveAt)
.FirstOrDefault();
if (latestVex is not null)
{
vexStatus = new TriageVexStatusDto
{
Status = latestVex.Status.ToString(),
Justification = latestVex.Justification,
ImpactStatement = latestVex.ImpactStatement,
IssuedBy = latestVex.IssuedBy,
IssuedAt = latestVex.IssuedAt,
VexDocumentRef = latestVex.VexDocumentRef
};
}
TriageReachabilityDto? reachability = null;
var latestReach = finding.ReachabilityResults
.OrderByDescending(r => r.AnalyzedAt)
.FirstOrDefault();
if (latestReach is not null)
{
reachability = new TriageReachabilityDto
{
Status = latestReach.Reachability.ToString(),
Confidence = latestReach.Confidence,
Source = latestReach.Source,
AnalyzedAt = latestReach.AnalyzedAt
};
}
TriageRiskScoreDto? riskScore = null;
var latestRisk = finding.RiskResults
.OrderByDescending(r => r.ComputedAt)
.FirstOrDefault();
if (latestRisk is not null)
{
riskScore = new TriageRiskScoreDto
{
Score = latestRisk.RiskScore,
CriticalCount = latestRisk.CriticalCount,
HighCount = latestRisk.HighCount,
MediumCount = latestRisk.MediumCount,
LowCount = latestRisk.LowCount,
EpssScore = latestRisk.EpssScore,
EpssPercentile = latestRisk.EpssPercentile
};
}
var evidence = finding.EvidenceArtifacts
.Select(e => new TriageEvidenceDto
{
Type = e.Type.ToString(),
Uri = e.Uri,
Digest = e.Digest,
CreatedAt = e.CreatedAt
})
.ToList();
// Compute counterfactuals for non-Ship verdicts
IReadOnlyList<string>? wouldPassIf = null;
if (verdict != "Ship")
{
wouldPassIf = ComputeWouldPassIf(finding, lane);
}
return new FindingTriageStatusDto
{
FindingId = finding.Id.ToString(),
Lane = lane,
Verdict = verdict,
Reason = GetReason(finding),
VexStatus = vexStatus,
Reachability = reachability,
RiskScore = riskScore,
WouldPassIf = wouldPassIf,
Evidence = evidence.Count > 0 ? evidence : null,
ComputedAt = _timeProvider.GetUtcNow(),
ProofBundleUri = $"/v1/triage/findings/{finding.Id}/proof-bundle"
};
}
private static string GetCurrentLane(TriageFinding finding)
{
var latestSnapshot = finding.Snapshots
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefault();
return latestSnapshot?.Lane.ToString() ?? "Active";
}
private static string GetCurrentVerdict(TriageFinding finding)
{
var latestSnapshot = finding.Snapshots
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefault();
return latestSnapshot?.Verdict.ToString() ?? "Block";
}
private static string? GetReason(TriageFinding finding)
{
var latestDecision = finding.Decisions
.OrderByDescending(d => d.DecidedAt)
.FirstOrDefault();
return latestDecision?.Reason;
}
private static string ComputeVerdict(string lane, string? decisionKind)
{
return lane switch
{
"MutedReach" => "Ship",
"MutedVex" => "Ship",
"Compensated" => "Ship",
"Blocked" => "Block",
"NeedsException" => decisionKind == "Exception" ? "Exception" : "Block",
_ => "Block"
};
}
private IReadOnlyList<string> ComputeWouldPassIf(TriageFinding finding, string currentLane)
{
var suggestions = new List<string>();
// Check VEX path
var latestVex = finding.EffectiveVexRecords
.OrderByDescending(v => v.EffectiveAt)
.FirstOrDefault();
if (latestVex is null || latestVex.Status != TriageVexStatus.NotAffected)
{
suggestions.Add("VEX status changed to 'not_affected'");
}
// Check reachability path
var latestReach = finding.ReachabilityResults
.OrderByDescending(r => r.AnalyzedAt)
.FirstOrDefault();
if (latestReach is null || latestReach.Reachability != TriageReachability.No)
{
suggestions.Add("Reachability analysis shows code is not reachable");
}
// Check exception path
if (!string.Equals(currentLane, "Compensated", StringComparison.OrdinalIgnoreCase))
{
suggestions.Add("Security exception is granted");
}
// Check version upgrade path
if (!string.IsNullOrWhiteSpace(finding.CveId))
{
suggestions.Add($"Component upgraded to version without {finding.CveId}");
}
return suggestions;
}
}

View File

@@ -9,7 +9,7 @@
<RootNamespace>StellaOps.Scanner.WebService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CycloneDX.Core" Version="11.0.0" />
<PackageReference Include="CycloneDX.Core" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />