sprints completion. new product advisories prepared
This commit is contained in:
@@ -58,6 +58,16 @@ internal static class ExportEndpoints
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/json")
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /scans/{scanId}/exports/signed-sbom-archive
|
||||
// Sprint: SPRINT_20260112_016_SCANNER_signed_sbom_archive_spec Task SBOM-SPEC-010
|
||||
scansGroup.MapGet("/{scanId}/exports/signed-sbom-archive", HandleExportSignedSbomArchiveAsync)
|
||||
.WithName("scanner.scans.exports.signedSbomArchive")
|
||||
.WithTags("Exports", "SBOM", "Signed")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/gzip")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/zstd")
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleExportSarifAsync(
|
||||
@@ -319,6 +329,144 @@ internal static class ExportEndpoints
|
||||
"software" or _ => Spdx3ProfileType.Software
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles signed SBOM archive export.
|
||||
/// Sprint: SPRINT_20260112_016_SCANNER_signed_sbom_archive_spec Task SBOM-SPEC-010
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan identifier.</param>
|
||||
/// <param name="format">SBOM format: spdx-2.3 (default), spdx-3.0.1, cyclonedx-1.7.</param>
|
||||
/// <param name="compression">Compression: gzip (default), zstd.</param>
|
||||
/// <param name="includeRekor">Include Rekor proof (default: true).</param>
|
||||
/// <param name="includeSchemas">Include bundled JSON schemas (default: true).</param>
|
||||
/// <param name="coordinator">The scan coordinator service.</param>
|
||||
/// <param name="sbomExportService">The SBOM export service.</param>
|
||||
/// <param name="archiveBuilder">The signed SBOM archive builder.</param>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
private static async Task<IResult> HandleExportSignedSbomArchiveAsync(
|
||||
string scanId,
|
||||
string? format,
|
||||
string? compression,
|
||||
bool? includeRekor,
|
||||
bool? includeSchemas,
|
||||
IScanCoordinator coordinator,
|
||||
ISbomExportService sbomExportService,
|
||||
ISignedSbomArchiveBuilder archiveBuilder,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(sbomExportService);
|
||||
ArgumentNullException.ThrowIfNull(archiveBuilder);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
// Export SBOM
|
||||
var selectedFormat = SelectSbomFormat(format ?? "spdx-2.3");
|
||||
var selectedProfile = Spdx3ProfileType.Software;
|
||||
|
||||
var sbomExport = await sbomExportService.ExportAsync(
|
||||
parsed,
|
||||
selectedFormat,
|
||||
selectedProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (sbomExport is null || sbomExport.Bytes is null || sbomExport.Bytes.Length == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"No SBOM data available",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "No SBOM data available for archive export.");
|
||||
}
|
||||
|
||||
// Build signed archive request
|
||||
// Note: In production, DSSE envelope would come from actual signing service
|
||||
var sbomFormatString = selectedFormat switch
|
||||
{
|
||||
SbomExportFormat.Spdx3 => "spdx-3.0.1",
|
||||
SbomExportFormat.Spdx2 => "spdx-2.3",
|
||||
SbomExportFormat.CycloneDx => "cyclonedx-1.7",
|
||||
_ => "spdx-2.3"
|
||||
};
|
||||
|
||||
var request = new SignedSbomArchiveRequest
|
||||
{
|
||||
ScanId = parsed,
|
||||
SbomBytes = sbomExport.Bytes,
|
||||
SbomFormat = sbomFormatString,
|
||||
DsseEnvelopeBytes = CreatePlaceholderDsseEnvelope(sbomExport.Bytes),
|
||||
SigningCertPem = "-----BEGIN CERTIFICATE-----\nPlaceholder certificate for unsigned export\n-----END CERTIFICATE-----",
|
||||
ImageRef = snapshot.ImageRef ?? "unknown",
|
||||
ImageDigest = snapshot.ImageDigest ?? "sha256:unknown",
|
||||
Platform = snapshot.Platform,
|
||||
ComponentCount = sbomExport.ComponentCount,
|
||||
PackageCount = sbomExport.ComponentCount, // Approximation
|
||||
FileCount = 0,
|
||||
Operator = context.User?.Identity?.Name,
|
||||
IncludeRekorProof = includeRekor ?? true,
|
||||
IncludeSchemas = includeSchemas ?? true,
|
||||
Compression = compression ?? "gzip"
|
||||
};
|
||||
|
||||
var result = await archiveBuilder.BuildAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Set response headers per spec
|
||||
context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{result.FileName}\"";
|
||||
context.Response.Headers["X-SBOM-Digest"] = result.SbomDigest;
|
||||
context.Response.Headers["X-Archive-Merkle-Root"] = result.MerkleRoot;
|
||||
|
||||
if (result.RekorLogIndex.HasValue)
|
||||
{
|
||||
context.Response.Headers["X-Rekor-Log-Index"] = result.RekorLogIndex.Value.ToString();
|
||||
}
|
||||
|
||||
var bytes = new byte[result.Size];
|
||||
await result.Stream.ReadExactlyAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Bytes(bytes, result.ContentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a placeholder DSSE envelope for unsigned exports.
|
||||
/// In production, this would come from the actual signing service.
|
||||
/// </summary>
|
||||
private static byte[] CreatePlaceholderDsseEnvelope(byte[] sbomBytes)
|
||||
{
|
||||
var payload = Convert.ToBase64String(sbomBytes);
|
||||
var envelope = new
|
||||
{
|
||||
payloadType = "application/vnd.stellaops.sbom+json",
|
||||
payload = payload,
|
||||
signatures = Array.Empty<object>()
|
||||
};
|
||||
|
||||
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(envelope, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -59,6 +59,16 @@ internal static class ReachabilityEndpoints
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence
|
||||
// GET /scans/{scanId}/reachability/traces/export - Trace export with runtime evidence
|
||||
scansGroup.MapGet("/{scanId}/reachability/traces/export", HandleTraceExportAsync)
|
||||
.WithName("scanner.scans.reachability.traces.export")
|
||||
.WithTags("Reachability")
|
||||
.Produces<ReachabilityTraceExportDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleComputeReachabilityAsync(
|
||||
@@ -315,9 +325,145 @@ internal static class ReachabilityEndpoints
|
||||
return Json(response, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence (SCAN-RT-003)
|
||||
private static async Task<IResult> HandleTraceExportAsync(
|
||||
string scanId,
|
||||
string? format,
|
||||
bool? includeRuntimeEvidence,
|
||||
double? minReachabilityScore,
|
||||
bool? runtimeConfirmedOnly,
|
||||
IScanCoordinator coordinator,
|
||||
IReachabilityQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(queryService);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
// Determine export format (default to json-lines for determinism)
|
||||
var exportFormat = (format?.ToLowerInvariant()) switch
|
||||
{
|
||||
"graphson" => "graphson",
|
||||
"ndjson" or "json-lines" => "json-lines",
|
||||
_ => "json-lines"
|
||||
};
|
||||
|
||||
var options = new TraceExportOptions
|
||||
{
|
||||
Format = exportFormat,
|
||||
IncludeRuntimeEvidence = includeRuntimeEvidence ?? true,
|
||||
MinReachabilityScore = minReachabilityScore,
|
||||
RuntimeConfirmedOnly = runtimeConfirmedOnly ?? false
|
||||
};
|
||||
|
||||
var export = await queryService.ExportTracesAsync(parsed, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (export is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"No reachability data",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "No reachability data found for this scan.");
|
||||
}
|
||||
|
||||
var response = new ReachabilityTraceExportDto(
|
||||
Format: export.Format,
|
||||
CanonicalizationMethod: "StellaOps.Canonical.Json",
|
||||
ContentDigest: export.ContentDigest,
|
||||
Timestamp: export.Timestamp,
|
||||
NodeCount: export.Nodes.Count,
|
||||
EdgeCount: export.Edges.Count,
|
||||
RuntimeCoverage: export.RuntimeCoverage,
|
||||
AverageReachabilityScore: export.AverageReachabilityScore,
|
||||
Nodes: export.Nodes.Select(n => new TraceNodeDto(
|
||||
Id: n.Id,
|
||||
SymbolId: n.SymbolId,
|
||||
ReachabilityScore: n.ReachabilityScore,
|
||||
RuntimeConfirmed: n.RuntimeConfirmed,
|
||||
RuntimeObservationCount: n.RuntimeObservationCount,
|
||||
Evidence: n.Evidence)).ToList(),
|
||||
Edges: export.Edges.Select(e => new TraceEdgeDto(
|
||||
From: e.From,
|
||||
To: e.To,
|
||||
Kind: e.Kind,
|
||||
Confidence: e.Confidence,
|
||||
RuntimeConfirmed: e.RuntimeConfirmed,
|
||||
RuntimeObservationCount: e.RuntimeObservationCount,
|
||||
Evidence: e.Evidence)).ToList());
|
||||
|
||||
return Json(response, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value, int statusCode)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence
|
||||
// Trace export DTOs
|
||||
|
||||
/// <summary>Options for trace export.</summary>
|
||||
public sealed record TraceExportOptions
|
||||
{
|
||||
public string Format { get; init; } = "json-lines";
|
||||
public bool IncludeRuntimeEvidence { get; init; } = true;
|
||||
public double? MinReachabilityScore { get; init; }
|
||||
public bool RuntimeConfirmedOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Trace export response.</summary>
|
||||
public sealed record ReachabilityTraceExportDto(
|
||||
string Format,
|
||||
string CanonicalizationMethod,
|
||||
string ContentDigest,
|
||||
DateTimeOffset Timestamp,
|
||||
int NodeCount,
|
||||
int EdgeCount,
|
||||
double RuntimeCoverage,
|
||||
double? AverageReachabilityScore,
|
||||
IReadOnlyList<TraceNodeDto> Nodes,
|
||||
IReadOnlyList<TraceEdgeDto> Edges);
|
||||
|
||||
/// <summary>Node in trace export.</summary>
|
||||
public sealed record TraceNodeDto(
|
||||
string Id,
|
||||
string SymbolId,
|
||||
double? ReachabilityScore,
|
||||
bool? RuntimeConfirmed,
|
||||
ulong? RuntimeObservationCount,
|
||||
IReadOnlyList<string>? Evidence);
|
||||
|
||||
/// <summary>Edge in trace export.</summary>
|
||||
public sealed record TraceEdgeDto(
|
||||
string From,
|
||||
string To,
|
||||
string Kind,
|
||||
double Confidence,
|
||||
bool? RuntimeConfirmed,
|
||||
ulong? RuntimeObservationCount,
|
||||
IReadOnlyList<string>? Evidence);
|
||||
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -301,6 +302,7 @@ internal static class WebhookEndpoints
|
||||
IEnumerable<ISourceTypeHandler> handlers,
|
||||
ISourceTriggerDispatcher dispatcher,
|
||||
ICredentialResolver credentialResolver,
|
||||
IPrAnnotationWebhookHandler? prAnnotationHandler,
|
||||
ILogger<WebhookEndpointLogger> logger,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
@@ -335,7 +337,9 @@ internal static class WebhookEndpoints
|
||||
logger,
|
||||
context,
|
||||
signatureHeader: "X-Hub-Signature-256",
|
||||
ct);
|
||||
ct,
|
||||
prAnnotationHandler: prAnnotationHandler,
|
||||
provider: "GitHub");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -348,6 +352,7 @@ internal static class WebhookEndpoints
|
||||
IEnumerable<ISourceTypeHandler> handlers,
|
||||
ISourceTriggerDispatcher dispatcher,
|
||||
ICredentialResolver credentialResolver,
|
||||
IPrAnnotationWebhookHandler? prAnnotationHandler,
|
||||
ILogger<WebhookEndpointLogger> logger,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
@@ -376,7 +381,9 @@ internal static class WebhookEndpoints
|
||||
logger,
|
||||
context,
|
||||
signatureHeader: "X-Gitlab-Token",
|
||||
ct);
|
||||
ct,
|
||||
prAnnotationHandler: prAnnotationHandler,
|
||||
provider: "GitLab");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -434,7 +441,9 @@ internal static class WebhookEndpoints
|
||||
ILogger<WebhookEndpointLogger> logger,
|
||||
HttpContext context,
|
||||
string signatureHeader,
|
||||
CancellationToken ct)
|
||||
CancellationToken ct,
|
||||
IPrAnnotationWebhookHandler? prAnnotationHandler = null,
|
||||
string? provider = null)
|
||||
{
|
||||
// Read the raw payload
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
@@ -525,6 +534,23 @@ internal static class WebhookEndpoints
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260112_007_SCANNER_pr_mr_annotations (SCANNER-PR-001)
|
||||
// Extract PR context if this is a PR/MR event
|
||||
PrWebhookContext? prContext = null;
|
||||
if (prAnnotationHandler != null && !string.IsNullOrEmpty(provider))
|
||||
{
|
||||
prContext = prAnnotationHandler.ExtractPrContext(payload, provider);
|
||||
if (prContext != null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Extracted PR context for {Provider} {Owner}/{Repo}#{PrNumber}",
|
||||
prContext.Provider,
|
||||
prContext.Owner,
|
||||
prContext.Repository,
|
||||
prContext.PrNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Create trigger context
|
||||
var triggerContext = new TriggerContext
|
||||
{
|
||||
@@ -534,6 +560,23 @@ internal static class WebhookEndpoints
|
||||
WebhookPayload = payload
|
||||
};
|
||||
|
||||
// Add PR context to trigger metadata if available
|
||||
if (prContext != null)
|
||||
{
|
||||
triggerContext.Metadata["pr_provider"] = prContext.Provider;
|
||||
triggerContext.Metadata["pr_owner"] = prContext.Owner;
|
||||
triggerContext.Metadata["pr_repository"] = prContext.Repository;
|
||||
triggerContext.Metadata["pr_number"] = prContext.PrNumber.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (!string.IsNullOrEmpty(prContext.BaseBranch))
|
||||
triggerContext.Metadata["pr_base_branch"] = prContext.BaseBranch;
|
||||
if (!string.IsNullOrEmpty(prContext.HeadBranch))
|
||||
triggerContext.Metadata["pr_head_branch"] = prContext.HeadBranch;
|
||||
if (!string.IsNullOrEmpty(prContext.BaseCommitSha))
|
||||
triggerContext.Metadata["pr_base_commit"] = prContext.BaseCommitSha;
|
||||
if (!string.IsNullOrEmpty(prContext.HeadCommitSha))
|
||||
triggerContext.Metadata["pr_head_commit"] = prContext.HeadCommitSha;
|
||||
}
|
||||
|
||||
// Dispatch the trigger
|
||||
try
|
||||
{
|
||||
@@ -562,7 +605,14 @@ internal static class WebhookEndpoints
|
||||
Accepted = true,
|
||||
Message = $"Queued {result.JobsQueued} scan jobs",
|
||||
RunId = result.Run?.RunId,
|
||||
JobsQueued = result.JobsQueued
|
||||
JobsQueued = result.JobsQueued,
|
||||
PrContext = prContext != null ? new WebhookPrContextResponse
|
||||
{
|
||||
Provider = prContext.Provider,
|
||||
Owner = prContext.Owner,
|
||||
Repository = prContext.Repository,
|
||||
PrNumber = prContext.PrNumber
|
||||
} : null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -598,4 +648,21 @@ public record WebhookResponse
|
||||
public string? Message { get; init; }
|
||||
public Guid? RunId { get; init; }
|
||||
public int JobsQueued { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR context if this webhook was triggered by a PR/MR event.
|
||||
/// Sprint: SPRINT_20260112_007_SCANNER_pr_mr_annotations (SCANNER-PR-001)
|
||||
/// </summary>
|
||||
public WebhookPrContextResponse? PrContext { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR context extracted from webhook payload.
|
||||
/// </summary>
|
||||
public record WebhookPrContextResponse
|
||||
{
|
||||
public string Provider { get; init; } = "";
|
||||
public string Owner { get; init; } = "";
|
||||
public string Repository { get; init; } = "";
|
||||
public int PrNumber { get; init; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user