Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class HealthEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static void MapHealthEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var group = endpoints.MapGroup("/");
|
||||
group.MapGet("/healthz", HandleHealth)
|
||||
.WithName("scanner.health")
|
||||
.Produces<HealthDocument>(StatusCodes.Status200OK)
|
||||
.AllowAnonymous();
|
||||
|
||||
group.MapGet("/readyz", HandleReady)
|
||||
.WithName("scanner.ready")
|
||||
.Produces<ReadyDocument>(StatusCodes.Status200OK)
|
||||
.AllowAnonymous();
|
||||
}
|
||||
|
||||
private static IResult HandleHealth(
|
||||
ServiceStatus status,
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
HttpContext context)
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
var snapshot = status.CreateSnapshot();
|
||||
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
|
||||
|
||||
var telemetry = new TelemetrySnapshot(
|
||||
Enabled: options.Value.Telemetry.Enabled,
|
||||
Logging: options.Value.Telemetry.EnableLogging,
|
||||
Metrics: options.Value.Telemetry.EnableMetrics,
|
||||
Tracing: options.Value.Telemetry.EnableTracing);
|
||||
|
||||
var document = new HealthDocument(
|
||||
Status: "healthy",
|
||||
StartedAt: snapshot.StartedAt,
|
||||
CapturedAt: snapshot.CapturedAt,
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
Telemetry: telemetry);
|
||||
|
||||
return Json(document, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleReady(
|
||||
ServiceStatus status,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
await Task.CompletedTask;
|
||||
|
||||
status.RecordReadyCheck(success: true, latency: TimeSpan.Zero, error: null);
|
||||
var snapshot = status.CreateSnapshot();
|
||||
var ready = snapshot.Ready;
|
||||
|
||||
var document = new ReadyDocument(
|
||||
Status: ready.IsReady ? "ready" : "unready",
|
||||
CheckedAt: ready.CheckedAt,
|
||||
LatencyMs: ready.Latency?.TotalMilliseconds,
|
||||
Error: ready.Error);
|
||||
|
||||
return Json(document, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static void ApplyNoCache(HttpResponse response)
|
||||
{
|
||||
response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
|
||||
response.Headers.Pragma = "no-cache";
|
||||
response.Headers["Expires"] = "0";
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value, int statusCode)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, JsonOptions);
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
|
||||
}
|
||||
|
||||
internal sealed record TelemetrySnapshot(
|
||||
bool Enabled,
|
||||
bool Logging,
|
||||
bool Metrics,
|
||||
bool Tracing);
|
||||
|
||||
internal sealed record HealthDocument(
|
||||
string Status,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset CapturedAt,
|
||||
double UptimeSeconds,
|
||||
TelemetrySnapshot Telemetry);
|
||||
|
||||
internal sealed record ReadyDocument(
|
||||
string Status,
|
||||
DateTimeOffset CheckedAt,
|
||||
double? LatencyMs,
|
||||
string? Error);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class PolicyEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
public static void MapPolicyEndpoints(this RouteGroupBuilder apiGroup, string policySegment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var policyGroup = apiGroup
|
||||
.MapGroup(NormalizeSegment(policySegment))
|
||||
.WithTags("Policy");
|
||||
|
||||
policyGroup.MapGet("/schema", HandleSchemaAsync)
|
||||
.WithName("scanner.policy.schema")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Retrieve the embedded policy JSON schema.";
|
||||
operation.Description = "Returns the policy schema (`policy-schema@1`) used to validate YAML or JSON rulesets.";
|
||||
return operation;
|
||||
});
|
||||
|
||||
policyGroup.MapPost("/diagnostics", HandleDiagnosticsAsync)
|
||||
.WithName("scanner.policy.diagnostics")
|
||||
.Produces<PolicyDiagnosticsResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Run policy diagnostics.";
|
||||
operation.Description = "Accepts YAML or JSON policy content and returns normalization issues plus recommendations (ignore rules, VEX include/exclude, vendor precedence).";
|
||||
return operation;
|
||||
});
|
||||
|
||||
policyGroup.MapPost("/preview", HandlePreviewAsync)
|
||||
.WithName("scanner.policy.preview")
|
||||
.Produces<PolicyPreviewResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Preview policy impact against findings.";
|
||||
operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages.";
|
||||
return operation;
|
||||
});
|
||||
|
||||
policyGroup.MapPost("/runtime", HandleRuntimePolicyAsync)
|
||||
.WithName("scanner.policy.runtime")
|
||||
.Produces<RuntimePolicyResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Evaluate runtime policy for digests.";
|
||||
operation.Description = "Returns per-image policy verdicts, signature and SBOM metadata, and cache hints for admission controllers.";
|
||||
return operation;
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult HandleSchemaAsync(HttpContext context)
|
||||
{
|
||||
var schema = PolicySchemaResource.ReadSchemaJson();
|
||||
return Results.Text(schema, "application/schema+json", Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static IResult HandleDiagnosticsAsync(
|
||||
PolicyDiagnosticsRequestDto request,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Content))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid policy diagnostics request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Policy content is required for diagnostics.");
|
||||
}
|
||||
|
||||
var format = PolicyDtoMapper.ParsePolicyFormat(request.Policy.Format);
|
||||
var binding = PolicyBinder.Bind(request.Policy.Content, format);
|
||||
var diagnostics = PolicyDiagnostics.Create(binding, timeProvider);
|
||||
|
||||
var response = new PolicyDiagnosticsResponseDto
|
||||
{
|
||||
Success = diagnostics.ErrorCount == 0,
|
||||
Version = diagnostics.Version,
|
||||
RuleCount = diagnostics.RuleCount,
|
||||
ErrorCount = diagnostics.ErrorCount,
|
||||
WarningCount = diagnostics.WarningCount,
|
||||
GeneratedAt = diagnostics.GeneratedAt,
|
||||
Issues = diagnostics.Issues.Select(PolicyDtoMapper.ToIssueDto).ToImmutableArray(),
|
||||
Recommendations = diagnostics.Recommendations
|
||||
};
|
||||
|
||||
return Json(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandlePreviewAsync(
|
||||
PolicyPreviewRequestDto request,
|
||||
PolicyPreviewService previewService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(previewService);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid policy preview request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "imageDigest is required.");
|
||||
}
|
||||
|
||||
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid policy preview request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
|
||||
}
|
||||
|
||||
if (request.Findings is not null)
|
||||
{
|
||||
var missingIds = request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id));
|
||||
if (missingIds)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid policy preview request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "All findings must include an id value.");
|
||||
}
|
||||
}
|
||||
|
||||
var domainRequest = PolicyDtoMapper.ToDomain(request);
|
||||
var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
var payload = PolicyDtoMapper.ToDto(response);
|
||||
return Json(payload);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRuntimePolicyAsync(
|
||||
RuntimePolicyRequestDto request,
|
||||
IRuntimePolicyService runtimePolicyService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(runtimePolicyService);
|
||||
|
||||
if (request.Images is null || request.Images.Count == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime policy request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "images collection must include at least one digest.");
|
||||
}
|
||||
|
||||
var normalizedImages = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var image in request.Images)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime policy request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Image digests must be non-empty.");
|
||||
}
|
||||
|
||||
var trimmed = image.Trim();
|
||||
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime policy request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Image digests must include an algorithm prefix (e.g. sha256:...).");
|
||||
}
|
||||
|
||||
if (seen.Add(trimmed))
|
||||
{
|
||||
normalizedImages.Add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedImages.Count == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime policy request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "images collection must include at least one unique digest.");
|
||||
}
|
||||
|
||||
var namespaceValue = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim();
|
||||
var normalizedLabels = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (request.Labels is not null)
|
||||
{
|
||||
foreach (var pair in request.Labels)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = pair.Key.Trim();
|
||||
var value = pair.Value?.Trim() ?? string.Empty;
|
||||
normalizedLabels[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
var evaluationRequest = new RuntimePolicyEvaluationRequest(
|
||||
namespaceValue,
|
||||
new ReadOnlyDictionary<string, string>(normalizedLabels),
|
||||
normalizedImages);
|
||||
|
||||
var evaluation = await runtimePolicyService.EvaluateAsync(evaluationRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var resultPayload = MapRuntimePolicyResponse(evaluation);
|
||||
return Json(resultPayload);
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return "/policy";
|
||||
}
|
||||
|
||||
var trimmed = segment.Trim('/');
|
||||
return "/" + trimmed;
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static RuntimePolicyResponseDto MapRuntimePolicyResponse(RuntimePolicyEvaluationResult evaluation)
|
||||
{
|
||||
var results = new Dictionary<string, RuntimePolicyImageResponseDto>(evaluation.Results.Count, StringComparer.Ordinal);
|
||||
foreach (var pair in evaluation.Results)
|
||||
{
|
||||
var decision = pair.Value;
|
||||
RuntimePolicyRekorDto? rekor = null;
|
||||
if (decision.Rekor is not null)
|
||||
{
|
||||
rekor = new RuntimePolicyRekorDto
|
||||
{
|
||||
Uuid = decision.Rekor.Uuid,
|
||||
Url = decision.Rekor.Url,
|
||||
Verified = decision.Rekor.Verified
|
||||
};
|
||||
}
|
||||
|
||||
string? metadata = null;
|
||||
if (decision.Metadata is not null && decision.Metadata.Count > 0)
|
||||
{
|
||||
metadata = JsonSerializer.Serialize(decision.Metadata, SerializerOptions);
|
||||
}
|
||||
|
||||
results[pair.Key] = new RuntimePolicyImageResponseDto
|
||||
{
|
||||
PolicyVerdict = ToCamelCase(decision.PolicyVerdict),
|
||||
Signed = decision.Signed,
|
||||
HasSbomReferrers = decision.HasSbomReferrers,
|
||||
HasSbomLegacy = decision.HasSbomReferrers,
|
||||
Reasons = decision.Reasons.ToArray(),
|
||||
Rekor = rekor,
|
||||
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
|
||||
Quieted = decision.Quieted,
|
||||
QuietedBy = decision.QuietedBy,
|
||||
Metadata = metadata,
|
||||
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null
|
||||
};
|
||||
}
|
||||
|
||||
return new RuntimePolicyResponseDto
|
||||
{
|
||||
TtlSeconds = evaluation.TtlSeconds,
|
||||
ExpiresAtUtc = evaluation.ExpiresAtUtc,
|
||||
PolicyRevision = evaluation.PolicyRevision,
|
||||
Results = results
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToCamelCase(RuntimePolicyVerdict verdict)
|
||||
=> verdict switch
|
||||
{
|
||||
RuntimePolicyVerdict.Pass => "pass",
|
||||
RuntimePolicyVerdict.Warn => "warn",
|
||||
RuntimePolicyVerdict.Fail => "fail",
|
||||
RuntimePolicyVerdict.Error => "error",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
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.Policy;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class ReportEndpoints
|
||||
{
|
||||
private const string PayloadType = "application/vnd.stellaops.report+json";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public static void MapReportEndpoints(this RouteGroupBuilder apiGroup, string reportsSegment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var reports = apiGroup
|
||||
.MapGroup(NormalizeSegment(reportsSegment))
|
||||
.WithTags("Reports");
|
||||
|
||||
reports.MapPost("/", HandleCreateReportAsync)
|
||||
.WithName("scanner.reports.create")
|
||||
.Produces<ReportResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status503ServiceUnavailable)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Assemble a signed scan report.";
|
||||
operation.Description = "Aggregates latest findings with the active policy snapshot, returning verdicts plus an optional DSSE envelope.";
|
||||
return operation;
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateReportAsync(
|
||||
ReportRequestDto request,
|
||||
PolicyPreviewService previewService,
|
||||
IReportSigner signer,
|
||||
TimeProvider timeProvider,
|
||||
IReportEventDispatcher eventDispatcher,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(previewService);
|
||||
ArgumentNullException.ThrowIfNull(signer);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(eventDispatcher);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid report request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "imageDigest is required.");
|
||||
}
|
||||
|
||||
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid report request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
|
||||
}
|
||||
|
||||
if (request.Findings is not null && request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id)))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid report request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "All findings must include an id value.");
|
||||
}
|
||||
|
||||
var previewDto = new PolicyPreviewRequestDto
|
||||
{
|
||||
ImageDigest = request.ImageDigest,
|
||||
Findings = request.Findings,
|
||||
Baseline = request.Baseline,
|
||||
Policy = null
|
||||
};
|
||||
|
||||
var domainRequest = PolicyDtoMapper.ToDomain(previewDto) with { ProposedPolicy = null };
|
||||
var preview = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!preview.Success)
|
||||
{
|
||||
var issues = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
|
||||
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["issues"] = issues
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Unable to assemble report",
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
detail: "No policy snapshot is available or validation failed.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
var projectedVerdicts = preview.Diffs
|
||||
.Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected))
|
||||
.ToArray();
|
||||
|
||||
var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
|
||||
var summary = BuildSummary(projectedVerdicts);
|
||||
var verdict = ComputeVerdict(projectedVerdicts);
|
||||
var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest);
|
||||
var generatedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var document = new ReportDocumentDto
|
||||
{
|
||||
ReportId = reportId,
|
||||
ImageDigest = request.ImageDigest!,
|
||||
GeneratedAt = generatedAt,
|
||||
Verdict = verdict,
|
||||
Policy = new ReportPolicyDto
|
||||
{
|
||||
RevisionId = preview.RevisionId,
|
||||
Digest = preview.PolicyDigest
|
||||
},
|
||||
Summary = summary,
|
||||
Verdicts = projectedVerdicts,
|
||||
Issues = issuesDto
|
||||
};
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
|
||||
var signature = signer.Sign(payloadBytes);
|
||||
DsseEnvelopeDto? envelope = null;
|
||||
if (signature is not null)
|
||||
{
|
||||
envelope = new DsseEnvelopeDto
|
||||
{
|
||||
PayloadType = PayloadType,
|
||||
Payload = Convert.ToBase64String(payloadBytes),
|
||||
Signatures = new[]
|
||||
{
|
||||
new DsseSignatureDto
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Algorithm = signature.Algorithm,
|
||||
Signature = signature.Signature
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var response = new ReportResponseDto
|
||||
{
|
||||
Report = document,
|
||||
Dsse = envelope
|
||||
};
|
||||
|
||||
await eventDispatcher
|
||||
.PublishAsync(request, preview, document, envelope, context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Json(response);
|
||||
}
|
||||
|
||||
private static ReportSummaryDto BuildSummary(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
|
||||
{
|
||||
if (verdicts.Count == 0)
|
||||
{
|
||||
return new ReportSummaryDto { Total = 0 };
|
||||
}
|
||||
|
||||
var blocked = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase));
|
||||
var warned = verdicts.Count(v =>
|
||||
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase));
|
||||
var ignored = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Ignored), StringComparison.OrdinalIgnoreCase));
|
||||
var quieted = verdicts.Count(v => v.Quiet is true);
|
||||
|
||||
return new ReportSummaryDto
|
||||
{
|
||||
Total = verdicts.Count,
|
||||
Blocked = blocked,
|
||||
Warned = warned,
|
||||
Ignored = ignored,
|
||||
Quieted = quieted
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeVerdict(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
|
||||
{
|
||||
if (verdicts.Count == 0)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "escalated";
|
||||
}
|
||||
|
||||
if (verdicts.Any(v =>
|
||||
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "warn";
|
||||
}
|
||||
|
||||
return "pass";
|
||||
}
|
||||
|
||||
private static string CreateReportId(string imageDigest, string policyDigest)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(imageDigest.Trim());
|
||||
builder.Append('|');
|
||||
builder.Append(policyDigest ?? string.Empty);
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
var hex = Convert.ToHexString(hash.AsSpan(0, 10)).ToLowerInvariant();
|
||||
return $"report-{hex}";
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return "/reports";
|
||||
}
|
||||
|
||||
var trimmed = segment.Trim('/');
|
||||
return "/" + trimmed;
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class RuntimeEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public static void MapRuntimeEndpoints(this RouteGroupBuilder apiGroup, string runtimeSegment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var runtime = apiGroup
|
||||
.MapGroup(NormalizeSegment(runtimeSegment))
|
||||
.WithTags("Runtime");
|
||||
|
||||
runtime.MapPost("/events", HandleRuntimeEventsAsync)
|
||||
.WithName("scanner.runtime.events.ingest")
|
||||
.Produces<RuntimeEventsIngestResponseDto>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status429TooManyRequests)
|
||||
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRuntimeEventsAsync(
|
||||
RuntimeEventsIngestRequestDto request,
|
||||
IRuntimeEventIngestionService ingestionService,
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(ingestionService);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var runtimeOptions = options.Value.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var validationError = ValidateRequest(request, runtimeOptions, context, out var envelopes);
|
||||
if (validationError is { } problem)
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var result = await ingestionService.IngestAsync(envelopes, request.BatchId, cancellationToken).ConfigureAwait(false);
|
||||
if (result.IsPayloadTooLarge)
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["payloadBytes"] = result.PayloadBytes,
|
||||
["maxPayloadBytes"] = result.PayloadLimit
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Runtime event batch too large",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Runtime batch payload exceeds configured budget.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
if (result.IsRateLimited)
|
||||
{
|
||||
var retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(result.RetryAfter.TotalSeconds));
|
||||
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["scope"] = result.RateLimitedScope,
|
||||
["key"] = result.RateLimitedKey,
|
||||
["retryAfterSeconds"] = retryAfterSeconds
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.RateLimited,
|
||||
"Runtime ingestion rate limited",
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
detail: "Runtime ingestion exceeded configured rate limits.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
var payload = new RuntimeEventsIngestResponseDto
|
||||
{
|
||||
Accepted = result.Accepted,
|
||||
Duplicates = result.Duplicates
|
||||
};
|
||||
|
||||
return Json(payload, StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
private static IResult? ValidateRequest(
|
||||
RuntimeEventsIngestRequestDto request,
|
||||
ScannerWebServiceOptions.RuntimeOptions runtimeOptions,
|
||||
HttpContext context,
|
||||
out IReadOnlyList<RuntimeEventEnvelope> envelopes)
|
||||
{
|
||||
envelopes = request.Events ?? Array.Empty<RuntimeEventEnvelope>();
|
||||
if (envelopes.Count == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "events array must include at least one item.");
|
||||
}
|
||||
|
||||
if (envelopes.Count > runtimeOptions.MaxBatchSize)
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["maxBatchSize"] = runtimeOptions.MaxBatchSize,
|
||||
["eventCount"] = envelopes.Count
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "events array exceeds allowed batch size.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
var seenEventIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < envelopes.Count; i++)
|
||||
{
|
||||
var envelope = envelopes[i];
|
||||
if (envelope is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}] must not be null.");
|
||||
}
|
||||
|
||||
if (!envelope.IsSupported())
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["schemaVersion"] = envelope.SchemaVersion
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Unsupported runtime schema version",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Runtime event schemaVersion is not supported.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
var runtimeEvent = envelope.Event;
|
||||
if (runtimeEvent is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].event must not be null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(runtimeEvent.EventId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].eventId is required.");
|
||||
}
|
||||
|
||||
if (!seenEventIds.Add(runtimeEvent.EventId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"Duplicate eventId detected within batch ('{runtimeEvent.EventId}').");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(runtimeEvent.Tenant))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].tenant is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(runtimeEvent.Node))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].node is required.");
|
||||
}
|
||||
|
||||
if (runtimeEvent.Workload is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].workload is required.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return "/runtime";
|
||||
}
|
||||
|
||||
var trimmed = segment.Trim('/');
|
||||
return "/" + trimmed;
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value, int statusCode)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Pipelines;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class ScanEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public static void MapScanEndpoints(this RouteGroupBuilder apiGroup, string scansSegment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var scans = apiGroup.MapGroup(NormalizeSegment(scansSegment));
|
||||
|
||||
scans.MapPost("/", HandleSubmitAsync)
|
||||
.WithName("scanner.scans.submit")
|
||||
.Produces<ScanSubmitResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status409Conflict)
|
||||
.RequireAuthorization(ScannerPolicies.ScansEnqueue);
|
||||
|
||||
scans.MapGet("/{scanId}", HandleStatusAsync)
|
||||
.WithName("scanner.scans.status")
|
||||
.Produces<ScanStatusResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
scans.MapGet("/{scanId}/events", HandleProgressStreamAsync)
|
||||
.WithName("scanner.scans.events")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSubmitAsync(
|
||||
ScanSubmitRequest request,
|
||||
IScanCoordinator coordinator,
|
||||
LinkGenerator links,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(links);
|
||||
|
||||
if (request.Image is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan submission",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Request image descriptor is required.");
|
||||
}
|
||||
|
||||
var reference = request.Image.Reference;
|
||||
var digest = request.Image.Digest;
|
||||
if (string.IsNullOrWhiteSpace(reference) && string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan submission",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Either image.reference or image.digest must be provided.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(digest) && !digest.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan submission",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Image digest must include algorithm prefix (e.g. sha256:...).");
|
||||
}
|
||||
|
||||
var target = new ScanTarget(reference, digest).Normalize();
|
||||
var metadata = NormalizeMetadata(request.Metadata);
|
||||
var submission = new ScanSubmission(
|
||||
Target: target,
|
||||
Force: request.Force,
|
||||
ClientRequestId: request.ClientRequestId?.Trim(),
|
||||
Metadata: metadata);
|
||||
|
||||
ScanSubmissionResult result;
|
||||
try
|
||||
{
|
||||
result = await coordinator.SubmitAsync(submission, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
var statusText = result.Snapshot.Status.ToString();
|
||||
var location = links.GetPathByName(
|
||||
httpContext: context,
|
||||
endpointName: "scanner.scans.status",
|
||||
values: new { scanId = result.Snapshot.ScanId.Value });
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(location))
|
||||
{
|
||||
context.Response.Headers.Location = location;
|
||||
}
|
||||
|
||||
var response = new ScanSubmitResponse(
|
||||
ScanId: result.Snapshot.ScanId.Value,
|
||||
Status: statusText,
|
||||
Location: location,
|
||||
Created: result.Created);
|
||||
|
||||
return Json(response, StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleStatusAsync(
|
||||
string scanId,
|
||||
IScanCoordinator coordinator,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
|
||||
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, context.RequestAborted).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
var response = new ScanStatusResponse(
|
||||
ScanId: snapshot.ScanId.Value,
|
||||
Status: snapshot.Status.ToString(),
|
||||
Image: new ScanStatusTarget(snapshot.Target.Reference, snapshot.Target.Digest),
|
||||
CreatedAt: snapshot.CreatedAt,
|
||||
UpdatedAt: snapshot.UpdatedAt,
|
||||
FailureReason: snapshot.FailureReason);
|
||||
|
||||
return Json(response, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleProgressStreamAsync(
|
||||
string scanId,
|
||||
string? format,
|
||||
IScanProgressReader progressReader,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(progressReader);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (!progressReader.Exists(parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
var streamFormat = string.Equals(format, "jsonl", StringComparison.OrdinalIgnoreCase)
|
||||
? "jsonl"
|
||||
: "sse";
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
context.Response.Headers["Connection"] = "keep-alive";
|
||||
|
||||
if (streamFormat == "jsonl")
|
||||
{
|
||||
context.Response.ContentType = "application/x-ndjson";
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Response.ContentType = "text/event-stream";
|
||||
}
|
||||
|
||||
await foreach (var progressEvent in progressReader.SubscribeAsync(parsed, context.RequestAborted).WithCancellation(context.RequestAborted))
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
scanId = progressEvent.ScanId.Value,
|
||||
sequence = progressEvent.Sequence,
|
||||
state = progressEvent.State,
|
||||
message = progressEvent.Message,
|
||||
timestamp = progressEvent.Timestamp,
|
||||
correlationId = progressEvent.CorrelationId,
|
||||
data = progressEvent.Data
|
||||
};
|
||||
|
||||
if (streamFormat == "jsonl")
|
||||
{
|
||||
await WriteJsonLineAsync(context.Response.BodyWriter, payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteSseAsync(context.Response.BodyWriter, payload, progressEvent, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await context.Response.BodyWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = pair.Key.Trim();
|
||||
var value = pair.Value?.Trim() ?? string.Empty;
|
||||
normalized[key] = value;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static async Task WriteJsonLineAsync(PipeWriter writer, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
||||
await writer.WriteAsync(jsonBytes, cancellationToken).ConfigureAwait(false);
|
||||
await writer.WriteAsync(new[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteSseAsync(PipeWriter writer, object payload, ScanProgressEvent progressEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
var eventName = progressEvent.State.ToLowerInvariant();
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("id: ").Append(progressEvent.Sequence).Append('\n');
|
||||
builder.Append("event: ").Append(eventName).Append('\n');
|
||||
builder.Append("data: ").Append(json).Append('\n');
|
||||
builder.Append('\n');
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
await writer.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return "/scans";
|
||||
}
|
||||
|
||||
var trimmed = segment.Trim('/');
|
||||
return "/" + trimmed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user