Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FindingEvidenceContracts.cs
|
||||
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
|
||||
// Description: Unified evidence API response contracts for findings.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Unified evidence response for a finding, combining reachability, boundary,
|
||||
/// VEX evidence, and score explanation.
|
||||
/// </summary>
|
||||
public sealed record FindingEvidenceResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public string FindingId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier (e.g., "CVE-2021-44228").
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public string Cve { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Component where the vulnerability was found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("component")]
|
||||
public ComponentRef? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachable call path from entrypoint to vulnerable sink.
|
||||
/// Each element is a fully-qualified name (FQN).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachable_path")]
|
||||
public IReadOnlyList<string>? ReachablePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint proof (how the code is exposed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoint")]
|
||||
public EntrypointProof? Entrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Boundary proof (surface exposure and controls).
|
||||
/// </summary>
|
||||
[JsonPropertyName("boundary")]
|
||||
public BoundaryProofDto? Boundary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX (Vulnerability Exploitability eXchange) evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex")]
|
||||
public VexEvidenceDto? Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score explanation with additive risk breakdown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("score_explain")]
|
||||
public ScoreExplanationDto? ScoreExplain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the finding was last observed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("last_seen")]
|
||||
public DateTimeOffset LastSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the evidence expires (for VEX/attestation freshness).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// References to DSSE/in-toto attestations backing this evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestation_refs")]
|
||||
public IReadOnlyList<string>? AttestationRefs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a component (package) by PURL and version.
|
||||
/// </summary>
|
||||
public sealed record ComponentRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (PURL) identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public string Purl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package type/ecosystem (npm, maven, nuget, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proof of how code is exposed as an entrypoint.
|
||||
/// </summary>
|
||||
public sealed record EntrypointProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of entrypoint (http_handler, grpc_method, cli_command, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Route or path (e.g., "/api/v1/users", "grpc.UserService.GetUser").
|
||||
/// </summary>
|
||||
[JsonPropertyName("route")]
|
||||
public string? Route { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP method if applicable (GET, POST, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("method")]
|
||||
public string? Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication requirement (none, optional, required).
|
||||
/// </summary>
|
||||
[JsonPropertyName("auth")]
|
||||
public string? Auth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase (startup, runtime, shutdown).
|
||||
/// </summary>
|
||||
[JsonPropertyName("phase")]
|
||||
public string? Phase { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fully qualified name of the entrypoint symbol.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fqn")]
|
||||
public string Fqn { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source file location.
|
||||
/// </summary>
|
||||
[JsonPropertyName("location")]
|
||||
public SourceLocation? Location { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source file location reference.
|
||||
/// </summary>
|
||||
public sealed record SourceLocation
|
||||
{
|
||||
/// <summary>
|
||||
/// File path relative to repository root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file")]
|
||||
public string File { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Line number (1-indexed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Column number (1-indexed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("column")]
|
||||
public int? Column { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boundary proof describing surface exposure and controls.
|
||||
/// </summary>
|
||||
public sealed record BoundaryProofDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Kind of boundary (network, file, ipc, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Surface descriptor (what is exposed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("surface")]
|
||||
public SurfaceDescriptor? Surface { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposure descriptor (how it's exposed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("exposure")]
|
||||
public ExposureDescriptor? Exposure { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication descriptor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("auth")]
|
||||
public AuthDescriptor? Auth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security controls in place.
|
||||
/// </summary>
|
||||
[JsonPropertyName("controls")]
|
||||
public IReadOnlyList<ControlDescriptor>? Controls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the boundary was last verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("last_seen")]
|
||||
public DateTimeOffset LastSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes what attack surface is exposed.
|
||||
/// </summary>
|
||||
public sealed record SurfaceDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of surface (api, web, cli, library).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Protocol (http, https, grpc, tcp).
|
||||
/// </summary>
|
||||
[JsonPropertyName("protocol")]
|
||||
public string? Protocol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Port number if network-exposed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("port")]
|
||||
public int? Port { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes how the surface is exposed.
|
||||
/// </summary>
|
||||
public sealed record ExposureDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Exposure level (public, internal, private).
|
||||
/// </summary>
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exposure is internet-facing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("internet_facing")]
|
||||
public bool InternetFacing { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Network zone (dmz, internal, trusted).
|
||||
/// </summary>
|
||||
[JsonPropertyName("zone")]
|
||||
public string? Zone { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes authentication requirements.
|
||||
/// </summary>
|
||||
public sealed record AuthDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether authentication is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication type (jwt, oauth2, basic, api_key).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required roles/scopes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("roles")]
|
||||
public IReadOnlyList<string>? Roles { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a security control.
|
||||
/// </summary>
|
||||
public sealed record ControlDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of control (rate_limit, waf, input_validation, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the control is active.
|
||||
/// </summary>
|
||||
[JsonPropertyName("active")]
|
||||
public bool Active { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Control configuration details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("config")]
|
||||
public string? Config { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX (Vulnerability Exploitability eXchange) evidence.
|
||||
/// </summary>
|
||||
public sealed record VexEvidenceDto
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX status (not_affected, affected, fixed, under_investigation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Impact statement explaining why not affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("impact")]
|
||||
public string? Impact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action statement (remediation steps).
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the VEX document/attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestation_ref")]
|
||||
public string? AttestationRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement was issued.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issued_at")]
|
||||
public DateTimeOffset? IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement expires.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the VEX statement (vendor, first-party, third-party).
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score explanation with additive breakdown of risk factors.
|
||||
/// </summary>
|
||||
public sealed record ScoreExplanationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Kind of scoring algorithm (stellaops_risk_v1, cvss_v4, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Final computed risk score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("risk_score")]
|
||||
public double RiskScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual score contributions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contributions")]
|
||||
public IReadOnlyList<ScoreContributionDto>? Contributions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the score was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("last_seen")]
|
||||
public DateTimeOffset LastSeen { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual contribution to the risk score.
|
||||
/// </summary>
|
||||
public sealed record ScoreContributionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Factor name (cvss_base, epss, reachability, gate_multiplier, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("factor")]
|
||||
public string Factor { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Weight applied to this factor (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public double Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw value before weighting.
|
||||
/// </summary>
|
||||
[JsonPropertyName("raw_value")]
|
||||
public double RawValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weighted contribution to final score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contribution")]
|
||||
public double Contribution { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation of this factor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("explanation")]
|
||||
public string? Explanation { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EpssEndpoints.cs
|
||||
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
|
||||
// Task: EPSS-SCAN-008, EPSS-SCAN-009
|
||||
// Description: EPSS lookup API endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// EPSS lookup API endpoints.
|
||||
/// Provides bulk lookup and history APIs for EPSS scores.
|
||||
/// </summary>
|
||||
public static class EpssEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps EPSS endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapEpssEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/epss")
|
||||
.WithTags("EPSS")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/current", GetCurrentBatch)
|
||||
.WithName("GetCurrentEpss")
|
||||
.WithSummary("Get current EPSS scores for multiple CVEs")
|
||||
.WithDescription("Returns the latest EPSS scores and percentiles for the specified CVE IDs. " +
|
||||
"Maximum batch size is 1000 CVEs per request.")
|
||||
.Produces<EpssBatchResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
group.MapGet("/current/{cveId}", GetCurrent)
|
||||
.WithName("GetCurrentEpssSingle")
|
||||
.WithSummary("Get current EPSS score for a single CVE")
|
||||
.WithDescription("Returns the latest EPSS score and percentile for the specified CVE ID.")
|
||||
.Produces<EpssEvidence>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/history/{cveId}", GetHistory)
|
||||
.WithName("GetEpssHistory")
|
||||
.WithSummary("Get EPSS score history for a CVE")
|
||||
.WithDescription("Returns the EPSS score time series for the specified CVE ID and date range.")
|
||||
.Produces<EpssHistoryResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/status", GetStatus)
|
||||
.WithName("GetEpssStatus")
|
||||
.WithSummary("Get EPSS data availability status")
|
||||
.WithDescription("Returns the current status of the EPSS data provider.")
|
||||
.Produces<EpssStatusResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /epss/current - Bulk lookup of current EPSS scores.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetCurrentBatch(
|
||||
[FromBody] EpssBatchRequest request,
|
||||
[FromServices] IEpssProvider epssProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.CveIds is null || request.CveIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one CVE ID is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.CveIds.Count > 1000)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Batch size exceeded",
|
||||
Detail = "Maximum batch size is 1000 CVE IDs.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
|
||||
if (!isAvailable)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "EPSS data is not available. Please ensure EPSS data has been ingested.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var result = await epssProvider.GetCurrentBatchAsync(request.CveIds, cancellationToken);
|
||||
|
||||
return Results.Ok(new EpssBatchResponse
|
||||
{
|
||||
Found = result.Found,
|
||||
NotFound = result.NotFound,
|
||||
ModelDate = result.ModelDate.ToString("yyyy-MM-dd"),
|
||||
LookupTimeMs = result.LookupTimeMs,
|
||||
PartiallyFromCache = result.PartiallyFromCache
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /epss/current/{cveId} - Get current EPSS score for a single CVE.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetCurrent(
|
||||
[FromRoute] string cveId,
|
||||
[FromServices] IEpssProvider epssProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid CVE ID",
|
||||
Detail = "CVE ID is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var evidence = await epssProvider.GetCurrentAsync(cveId, cancellationToken);
|
||||
|
||||
if (evidence is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "CVE not found",
|
||||
Detail = $"No EPSS score found for {cveId}.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(evidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /epss/history/{cveId} - Get EPSS score history for a CVE.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetHistory(
|
||||
[FromRoute] string cveId,
|
||||
[FromServices] IEpssProvider epssProvider,
|
||||
[FromQuery] string? startDate = null,
|
||||
[FromQuery] string? endDate = null,
|
||||
[FromQuery] int days = 30,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid CVE ID",
|
||||
Detail = "CVE ID is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
DateOnly start, end;
|
||||
|
||||
if (!string.IsNullOrEmpty(startDate) && !string.IsNullOrEmpty(endDate))
|
||||
{
|
||||
if (!DateOnly.TryParse(startDate, out start) || !DateOnly.TryParse(endDate, out end))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid date format",
|
||||
Detail = "Dates must be in yyyy-MM-dd format.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to last N days
|
||||
end = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
start = end.AddDays(-days);
|
||||
}
|
||||
|
||||
var history = await epssProvider.GetHistoryAsync(cveId, start, end, cancellationToken);
|
||||
|
||||
if (history.Count == 0)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "No history found",
|
||||
Detail = $"No EPSS history found for {cveId} in the specified date range.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new EpssHistoryResponse
|
||||
{
|
||||
CveId = cveId,
|
||||
StartDate = start.ToString("yyyy-MM-dd"),
|
||||
EndDate = end.ToString("yyyy-MM-dd"),
|
||||
History = history
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /epss/status - Get EPSS data availability status.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetStatus(
|
||||
[FromServices] IEpssProvider epssProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
|
||||
var modelDate = await epssProvider.GetLatestModelDateAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new EpssStatusResponse
|
||||
{
|
||||
Available = isAvailable,
|
||||
LatestModelDate = modelDate?.ToString("yyyy-MM-dd"),
|
||||
LastCheckedUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request for bulk EPSS lookup.
|
||||
/// </summary>
|
||||
public sealed record EpssBatchRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// List of CVE IDs to look up (max 1000).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required IReadOnlyList<string> CveIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for bulk EPSS lookup.
|
||||
/// </summary>
|
||||
public sealed record EpssBatchResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// EPSS evidence for found CVEs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EpssEvidence> Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE IDs that were not found in the EPSS dataset.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> NotFound { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS model date used for this lookup.
|
||||
/// </summary>
|
||||
public required string ModelDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total lookup time in milliseconds.
|
||||
/// </summary>
|
||||
public long LookupTimeMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether any results came from cache.
|
||||
/// </summary>
|
||||
public bool PartiallyFromCache { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for EPSS history lookup.
|
||||
/// </summary>
|
||||
public sealed record EpssHistoryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of date range.
|
||||
/// </summary>
|
||||
public required string StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of date range.
|
||||
/// </summary>
|
||||
public required string EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Historical EPSS evidence records.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EpssEvidence> History { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for EPSS status check.
|
||||
/// </summary>
|
||||
public sealed record EpssStatusResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether EPSS data is available.
|
||||
/// </summary>
|
||||
public bool Available { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest EPSS model date available.
|
||||
/// </summary>
|
||||
public string? LatestModelDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this status was checked.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastCheckedUtc { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,251 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WitnessEndpoints.cs
|
||||
// Sprint: SPRINT_3700_0001_0001_witness_foundation
|
||||
// Task: WIT-010
|
||||
// Description: API endpoints for DSSE-signed path witnesses.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class WitnessEndpoints
|
||||
{
|
||||
public static void MapWitnessEndpoints(this RouteGroupBuilder apiGroup, string witnessSegment = "witnesses")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var witnesses = apiGroup.MapGroup($"/{witnessSegment.TrimStart('/')}");
|
||||
|
||||
witnesses.MapGet("/{witnessId:guid}", HandleGetWitnessByIdAsync)
|
||||
.WithName("scanner.witnesses.get")
|
||||
.Produces<WitnessResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
witnesses.MapGet("", HandleListWitnessesAsync)
|
||||
.WithName("scanner.witnesses.list")
|
||||
.Produces<WitnessListResponseDto>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
witnesses.MapGet("/by-hash/{witnessHash}", HandleGetWitnessByHashAsync)
|
||||
.WithName("scanner.witnesses.get-by-hash")
|
||||
.Produces<WitnessResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
witnesses.MapPost("/{witnessId:guid}/verify", HandleVerifyWitnessAsync)
|
||||
.WithName("scanner.witnesses.verify")
|
||||
.Produces<WitnessVerificationResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetWitnessByIdAsync(
|
||||
Guid witnessId,
|
||||
IWitnessRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
var witness = await repository.GetByIdAsync(witnessId, cancellationToken).ConfigureAwait(false);
|
||||
if (witness is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDto(witness));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetWitnessByHashAsync(
|
||||
string witnessHash,
|
||||
IWitnessRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(witnessHash))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var witness = await repository.GetByHashAsync(witnessHash, cancellationToken).ConfigureAwait(false);
|
||||
if (witness is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDto(witness));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListWitnessesAsync(
|
||||
HttpContext context,
|
||||
IWitnessRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
var query = context.Request.Query;
|
||||
IReadOnlyList<WitnessRecord> witnesses;
|
||||
|
||||
if (query.TryGetValue("scanId", out var scanIdValue) && Guid.TryParse(scanIdValue, out var scanId))
|
||||
{
|
||||
witnesses = await repository.GetByScanIdAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (query.TryGetValue("cve", out var cveValue) && !string.IsNullOrWhiteSpace(cveValue))
|
||||
{
|
||||
witnesses = await repository.GetByCveAsync(cveValue!, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (query.TryGetValue("graphHash", out var graphHashValue) && !string.IsNullOrWhiteSpace(graphHashValue))
|
||||
{
|
||||
witnesses = await repository.GetByGraphHashAsync(graphHashValue!, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No filter provided - return empty list (avoid full table scan)
|
||||
witnesses = [];
|
||||
}
|
||||
|
||||
return Results.Ok(new WitnessListResponseDto
|
||||
{
|
||||
Witnesses = witnesses.Select(MapToDto).ToList(),
|
||||
TotalCount = witnesses.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleVerifyWitnessAsync(
|
||||
Guid witnessId,
|
||||
IWitnessRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
var witness = await repository.GetByIdAsync(witnessId, cancellationToken).ConfigureAwait(false);
|
||||
if (witness is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// Basic verification: check if DSSE envelope exists and witness hash is valid
|
||||
var verificationStatus = "valid";
|
||||
string? verificationError = null;
|
||||
|
||||
if (string.IsNullOrEmpty(witness.DsseEnvelope))
|
||||
{
|
||||
verificationStatus = "unsigned";
|
||||
verificationError = "Witness does not have a DSSE envelope";
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: WIT-009 - Add actual DSSE signature verification via Attestor
|
||||
// For now, just check the envelope structure
|
||||
try
|
||||
{
|
||||
var envelope = JsonDocument.Parse(witness.DsseEnvelope);
|
||||
if (!envelope.RootElement.TryGetProperty("signatures", out var signatures) ||
|
||||
signatures.GetArrayLength() == 0)
|
||||
{
|
||||
verificationStatus = "invalid";
|
||||
verificationError = "DSSE envelope has no signatures";
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
verificationStatus = "invalid";
|
||||
verificationError = $"Invalid DSSE envelope JSON: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// Record verification attempt
|
||||
await repository.RecordVerificationAsync(new WitnessVerificationRecord
|
||||
{
|
||||
WitnessId = witnessId,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifiedBy = "api",
|
||||
VerificationStatus = verificationStatus,
|
||||
VerificationError = verificationError
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new WitnessVerificationResponseDto
|
||||
{
|
||||
WitnessId = witnessId,
|
||||
WitnessHash = witness.WitnessHash,
|
||||
Status = verificationStatus,
|
||||
Error = verificationError,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
IsSigned = !string.IsNullOrEmpty(witness.DsseEnvelope)
|
||||
});
|
||||
}
|
||||
|
||||
private static WitnessResponseDto MapToDto(WitnessRecord record)
|
||||
{
|
||||
return new WitnessResponseDto
|
||||
{
|
||||
WitnessId = record.WitnessId,
|
||||
WitnessHash = record.WitnessHash,
|
||||
SchemaVersion = record.SchemaVersion,
|
||||
WitnessType = record.WitnessType,
|
||||
GraphHash = record.GraphHash,
|
||||
ScanId = record.ScanId,
|
||||
RunId = record.RunId,
|
||||
CreatedAt = record.CreatedAt,
|
||||
SignedAt = record.SignedAt,
|
||||
SignerKeyId = record.SignerKeyId,
|
||||
EntrypointFqn = record.EntrypointFqn,
|
||||
SinkCve = record.SinkCve,
|
||||
IsSigned = !string.IsNullOrEmpty(record.DsseEnvelope),
|
||||
Payload = JsonDocument.Parse(record.PayloadJson).RootElement,
|
||||
DsseEnvelope = string.IsNullOrEmpty(record.DsseEnvelope)
|
||||
? null
|
||||
: JsonDocument.Parse(record.DsseEnvelope).RootElement
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for a single witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessResponseDto
|
||||
{
|
||||
public Guid WitnessId { get; init; }
|
||||
public required string WitnessHash { get; init; }
|
||||
public required string SchemaVersion { get; init; }
|
||||
public required string WitnessType { get; init; }
|
||||
public required string GraphHash { get; init; }
|
||||
public Guid? ScanId { get; init; }
|
||||
public Guid? RunId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
public string? SignerKeyId { get; init; }
|
||||
public string? EntrypointFqn { get; init; }
|
||||
public string? SinkCve { get; init; }
|
||||
public bool IsSigned { get; init; }
|
||||
public JsonElement Payload { get; init; }
|
||||
public JsonElement? DsseEnvelope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for witness list.
|
||||
/// </summary>
|
||||
public sealed record WitnessListResponseDto
|
||||
{
|
||||
public required IReadOnlyList<WitnessResponseDto> Witnesses { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for witness verification.
|
||||
/// </summary>
|
||||
public sealed record WitnessVerificationResponseDto
|
||||
{
|
||||
public Guid WitnessId { get; init; }
|
||||
public required string WitnessHash { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
public bool IsSigned { get; init; }
|
||||
}
|
||||
@@ -470,6 +470,7 @@ apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
|
||||
apiGroup.MapReachabilityDriftRootEndpoints();
|
||||
apiGroup.MapProofSpineEndpoints(resolvedOptions.Api.SpinesSegment, resolvedOptions.Api.ScansSegment);
|
||||
apiGroup.MapReplayEndpoints();
|
||||
apiGroup.MapWitnessEndpoints(); // Sprint: SPRINT_3700_0001_0001
|
||||
|
||||
if (resolvedOptions.Features.EnablePolicyPreview)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user