sprints completion. new product advisories prepared

This commit is contained in:
master
2026-01-16 16:30:03 +02:00
parent a927d924e3
commit 4ca3ce8fb4
255 changed files with 42434 additions and 1020 deletions

View File

@@ -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>

View File

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

View File

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