up
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Scanner.WebService.Constants;
|
||||
|
||||
internal static class ProblemTypes
|
||||
{
|
||||
public const string Validation = "https://stellaops.org/problems/validation";
|
||||
public const string Conflict = "https://stellaops.org/problems/conflict";
|
||||
public const string NotFound = "https://stellaops.org/problems/not-found";
|
||||
public const string InternalError = "https://stellaops.org/problems/internal-error";
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record PolicyPreviewRequestDto
|
||||
{
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("findings")]
|
||||
public IReadOnlyList<PolicyPreviewFindingDto>? Findings { get; init; }
|
||||
|
||||
[JsonPropertyName("baseline")]
|
||||
public IReadOnlyList<PolicyPreviewVerdictDto>? Baseline { get; init; }
|
||||
|
||||
[JsonPropertyName("policy")]
|
||||
public PolicyPreviewPolicyDto? Policy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyPreviewFindingDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("vendor")]
|
||||
public string? Vendor { get; init; }
|
||||
|
||||
[JsonPropertyName("license")]
|
||||
public string? License { get; init; }
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string? Image { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string? Repository { get; init; }
|
||||
|
||||
[JsonPropertyName("package")]
|
||||
public string? Package { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cve")]
|
||||
public string? Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string? LayerDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyPreviewVerdictDto
|
||||
{
|
||||
[JsonPropertyName("findingId")]
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleName")]
|
||||
public string? RuleName { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleAction")]
|
||||
public string? RuleAction { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("configVersion")]
|
||||
public string? ConfigVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, double>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("quietedBy")]
|
||||
public string? QuietedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("quiet")]
|
||||
public bool? Quiet { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyPreviewPolicyDto
|
||||
{
|
||||
[JsonPropertyName("content")]
|
||||
public string? Content { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyPreviewResponseDto
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("policyDigest")]
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("revisionId")]
|
||||
public string? RevisionId { get; init; }
|
||||
|
||||
[JsonPropertyName("changed")]
|
||||
public int Changed { get; init; }
|
||||
|
||||
[JsonPropertyName("diffs")]
|
||||
public IReadOnlyList<PolicyPreviewDiffDto> Diffs { get; init; } = Array.Empty<PolicyPreviewDiffDto>();
|
||||
|
||||
[JsonPropertyName("issues")]
|
||||
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
|
||||
}
|
||||
|
||||
public sealed record PolicyPreviewDiffDto
|
||||
{
|
||||
[JsonPropertyName("findingId")]
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
[JsonPropertyName("baseline")]
|
||||
public PolicyPreviewVerdictDto? Baseline { get; init; }
|
||||
|
||||
[JsonPropertyName("projected")]
|
||||
public PolicyPreviewVerdictDto? Projected { get; init; }
|
||||
|
||||
[JsonPropertyName("changed")]
|
||||
public bool Changed { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyPreviewIssueDto
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record ScanStatusResponse(
|
||||
string ScanId,
|
||||
string Status,
|
||||
ScanStatusTarget Image,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? FailureReason);
|
||||
|
||||
public sealed record ScanStatusTarget(
|
||||
string? Reference,
|
||||
string? Digest);
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record ScanSubmitRequest
|
||||
{
|
||||
public required ScanImageDescriptor Image { get; init; } = new();
|
||||
|
||||
public bool Force { get; init; }
|
||||
|
||||
public string? ClientRequestId { get; init; }
|
||||
|
||||
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed record ScanImageDescriptor
|
||||
{
|
||||
public string? Reference { get; init; }
|
||||
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record ScanSubmitResponse(
|
||||
string ScanId,
|
||||
string Status,
|
||||
string? Location,
|
||||
bool Created);
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks runtime health snapshots for the Scanner WebService.
|
||||
/// </summary>
|
||||
public sealed class ServiceStatus
|
||||
{
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly DateTimeOffset startedAt;
|
||||
private ReadySnapshot readySnapshot;
|
||||
|
||||
public ServiceStatus(TimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
startedAt = timeProvider.GetUtcNow();
|
||||
readySnapshot = ReadySnapshot.CreateInitial(startedAt);
|
||||
}
|
||||
|
||||
public ServiceSnapshot CreateSnapshot()
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return new ServiceSnapshot(startedAt, now, readySnapshot);
|
||||
}
|
||||
|
||||
public void RecordReadyCheck(bool success, TimeSpan latency, string? error)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
readySnapshot = new ReadySnapshot(now, latency, success, success ? null : error);
|
||||
}
|
||||
|
||||
public readonly record struct ServiceSnapshot(
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset CapturedAt,
|
||||
ReadySnapshot Ready);
|
||||
|
||||
public readonly record struct ReadySnapshot(
|
||||
DateTimeOffset CheckedAt,
|
||||
TimeSpan? Latency,
|
||||
bool IsReady,
|
||||
string? Error)
|
||||
{
|
||||
public static ReadySnapshot CreateInitial(DateTimeOffset timestamp)
|
||||
=> new ReadySnapshot(timestamp, null, true, null);
|
||||
}
|
||||
}
|
||||
18
src/StellaOps.Scanner.WebService/Domain/ScanId.cs
Normal file
18
src/StellaOps.Scanner.WebService/Domain/ScanId.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public readonly record struct ScanId(string Value)
|
||||
{
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static bool TryParse(string? value, out ScanId scanId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
scanId = new ScanId(value.Trim());
|
||||
return true;
|
||||
}
|
||||
|
||||
scanId = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
12
src/StellaOps.Scanner.WebService/Domain/ScanProgressEvent.cs
Normal file
12
src/StellaOps.Scanner.WebService/Domain/ScanProgressEvent.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public sealed record ScanProgressEvent(
|
||||
ScanId ScanId,
|
||||
int Sequence,
|
||||
DateTimeOffset Timestamp,
|
||||
string State,
|
||||
string? Message,
|
||||
string CorrelationId,
|
||||
IReadOnlyDictionary<string, object?> Data);
|
||||
9
src/StellaOps.Scanner.WebService/Domain/ScanSnapshot.cs
Normal file
9
src/StellaOps.Scanner.WebService/Domain/ScanSnapshot.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public sealed record ScanSnapshot(
|
||||
ScanId ScanId,
|
||||
ScanTarget Target,
|
||||
ScanStatus Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? FailureReason);
|
||||
10
src/StellaOps.Scanner.WebService/Domain/ScanStatus.cs
Normal file
10
src/StellaOps.Scanner.WebService/Domain/ScanStatus.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public enum ScanStatus
|
||||
{
|
||||
Pending,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
||||
13
src/StellaOps.Scanner.WebService/Domain/ScanSubmission.cs
Normal file
13
src/StellaOps.Scanner.WebService/Domain/ScanSubmission.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public sealed record ScanSubmission(
|
||||
ScanTarget Target,
|
||||
bool Force,
|
||||
string? ClientRequestId,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
public sealed record ScanSubmissionResult(
|
||||
ScanSnapshot Snapshot,
|
||||
bool Created);
|
||||
11
src/StellaOps.Scanner.WebService/Domain/ScanTarget.cs
Normal file
11
src/StellaOps.Scanner.WebService/Domain/ScanTarget.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public sealed record ScanTarget(string? Reference, string? Digest)
|
||||
{
|
||||
public ScanTarget Normalize()
|
||||
{
|
||||
var normalizedReference = string.IsNullOrWhiteSpace(Reference) ? null : Reference.Trim();
|
||||
var normalizedDigest = string.IsNullOrWhiteSpace(Digest) ? null : Digest.Trim().ToLowerInvariant();
|
||||
return new ScanTarget(normalizedReference, normalizedDigest);
|
||||
}
|
||||
}
|
||||
112
src/StellaOps.Scanner.WebService/Endpoints/HealthEndpoints.cs
Normal file
112
src/StellaOps.Scanner.WebService/Endpoints/HealthEndpoints.cs
Normal file
@@ -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);
|
||||
}
|
||||
298
src/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs
Normal file
298
src/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs
Normal file
@@ -0,0 +1,298 @@
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var scans = apiGroup.MapGroup("/scans");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Scanner-specific configuration helpers.
|
||||
/// </summary>
|
||||
public static class ConfigurationExtensions
|
||||
{
|
||||
public static IConfigurationBuilder AddScannerYaml(this IConfigurationBuilder builder, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
using var reader = File.OpenText(path);
|
||||
var yamlObject = deserializer.Deserialize(reader);
|
||||
if (yamlObject is null)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(yamlObject);
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
|
||||
return builder.AddJsonStream(stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Hosting;
|
||||
|
||||
internal static class ScannerPluginHostFactory
|
||||
{
|
||||
public static PluginHostOptions Build(ScannerWebServiceOptions options, string contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(contentRootPath);
|
||||
|
||||
var baseDirectory = options.Plugins.BaseDirectory;
|
||||
if (string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
baseDirectory = Path.Combine(contentRootPath, "..");
|
||||
}
|
||||
else if (!Path.IsPathRooted(baseDirectory))
|
||||
{
|
||||
baseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, baseDirectory));
|
||||
}
|
||||
|
||||
var pluginsDirectory = options.Plugins.Directory;
|
||||
if (string.IsNullOrWhiteSpace(pluginsDirectory))
|
||||
{
|
||||
pluginsDirectory = Path.Combine("plugins", "scanner");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(pluginsDirectory))
|
||||
{
|
||||
pluginsDirectory = Path.Combine(baseDirectory, pluginsDirectory);
|
||||
}
|
||||
|
||||
var hostOptions = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = baseDirectory,
|
||||
PluginsDirectory = pluginsDirectory,
|
||||
PrimaryPrefix = "StellaOps.Scanner"
|
||||
};
|
||||
|
||||
foreach (var additionalPrefix in options.Plugins.OrderedPlugins)
|
||||
{
|
||||
hostOptions.PluginOrder.Add(additionalPrefix);
|
||||
}
|
||||
|
||||
foreach (var pattern in options.Plugins.SearchPatterns)
|
||||
{
|
||||
hostOptions.SearchPatterns.Add(pattern);
|
||||
}
|
||||
|
||||
return hostOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Infrastructure;
|
||||
|
||||
internal static class ProblemResultFactory
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public static IResult Create(
|
||||
HttpContext context,
|
||||
string type,
|
||||
string title,
|
||||
int statusCode,
|
||||
string? detail = null,
|
||||
IDictionary<string, object?>? extensions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(type);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
|
||||
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Type = type,
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = statusCode,
|
||||
Instance = context.Request.Path
|
||||
};
|
||||
|
||||
problem.Extensions["traceId"] = traceId;
|
||||
if (extensions is not null)
|
||||
{
|
||||
foreach (var entry in extensions)
|
||||
{
|
||||
problem.Extensions[entry.Key] = entry.Value;
|
||||
}
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(problem, JsonOptions);
|
||||
return Results.Content(payload, "application/problem+json", Encoding.UTF8, statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed configuration for the Scanner WebService host.
|
||||
/// </summary>
|
||||
public sealed class ScannerWebServiceOptions
|
||||
{
|
||||
public const string SectionName = "scanner";
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for configuration consumers to coordinate breaking changes.
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Mongo storage configuration used for catalog and job state.
|
||||
/// </summary>
|
||||
public StorageOptions Storage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Queue configuration used to enqueue scan jobs.
|
||||
/// </summary>
|
||||
public QueueOptions Queue { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Object store configuration for SBOM artefacts.
|
||||
/// </summary>
|
||||
public ArtifactStoreOptions ArtifactStore { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Feature flags toggling optional behaviours.
|
||||
/// </summary>
|
||||
public FeatureFlagOptions Features { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Plug-in loader configuration.
|
||||
/// </summary>
|
||||
public PluginOptions Plugins { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry configuration for logs, metrics, traces.
|
||||
/// </summary>
|
||||
public TelemetryOptions Telemetry { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Authority / authentication configuration.
|
||||
/// </summary>
|
||||
public AuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signing configuration for report envelopes and attestations.
|
||||
/// </summary>
|
||||
public SigningOptions Signing { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// API-specific settings such as base path.
|
||||
/// </summary>
|
||||
public ApiOptions Api { get; set; } = new();
|
||||
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
public string Driver { get; set; } = "mongo";
|
||||
|
||||
public string Dsn { get; set; } = string.Empty;
|
||||
|
||||
public string? Database { get; set; }
|
||||
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public int HealthCheckTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
public IList<string> Migrations { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class QueueOptions
|
||||
{
|
||||
public string Driver { get; set; } = "redis";
|
||||
|
||||
public string Dsn { get; set; } = string.Empty;
|
||||
|
||||
public string Namespace { get; set; } = "scanner";
|
||||
|
||||
public int VisibilityTimeoutSeconds { get; set; } = 300;
|
||||
|
||||
public int LeaseHeartbeatSeconds { get; set; } = 30;
|
||||
|
||||
public int MaxDeliveryAttempts { get; set; } = 5;
|
||||
|
||||
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class ArtifactStoreOptions
|
||||
{
|
||||
public string Driver { get; set; } = "minio";
|
||||
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
public bool UseTls { get; set; } = true;
|
||||
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
public string? SecretKeyFile { get; set; }
|
||||
|
||||
public string Bucket { get; set; } = "scanner-artifacts";
|
||||
|
||||
public string? Region { get; set; }
|
||||
|
||||
public bool EnableObjectLock { get; set; } = true;
|
||||
|
||||
public int ObjectLockRetentionDays { get; set; } = 30;
|
||||
|
||||
public IDictionary<string, string> Headers { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class FeatureFlagOptions
|
||||
{
|
||||
public bool AllowAnonymousScanSubmission { get; set; }
|
||||
|
||||
public bool EnableSignedReports { get; set; } = true;
|
||||
|
||||
public bool EnablePolicyPreview { get; set; } = true;
|
||||
|
||||
public IDictionary<string, bool> Experimental { get; set; } = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class PluginOptions
|
||||
{
|
||||
public string? BaseDirectory { get; set; }
|
||||
|
||||
public string? Directory { get; set; }
|
||||
|
||||
public IList<string> SearchPatterns { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> OrderedPlugins { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class TelemetryOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public bool EnableTracing { get; set; } = true;
|
||||
|
||||
public bool EnableMetrics { get; set; } = true;
|
||||
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
public bool EnableRequestLogging { get; set; } = true;
|
||||
|
||||
public string MinimumLogLevel { get; set; } = "Information";
|
||||
|
||||
public string? ServiceName { get; set; }
|
||||
|
||||
public string? OtlpEndpoint { get; set; }
|
||||
|
||||
public IDictionary<string, string> OtlpHeaders { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IDictionary<string, string> ResourceAttributes { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class AuthorityOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public bool AllowAnonymousFallback { get; set; } = true;
|
||||
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public int TokenClockSkewSeconds { get; set; } = 60;
|
||||
|
||||
public IList<string> Audiences { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> RequiredScopes { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> BypassNetworks { get; set; } = new List<string>();
|
||||
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
public string? ClientSecretFile { get; set; }
|
||||
|
||||
public IList<string> ClientScopes { get; set; } = new List<string>();
|
||||
|
||||
public ResilienceOptions Resilience { get; set; } = new();
|
||||
|
||||
public sealed class ResilienceOptions
|
||||
{
|
||||
public bool? EnableRetries { get; set; }
|
||||
|
||||
public IList<TimeSpan> RetryDelays { get; set; } = new List<TimeSpan>();
|
||||
|
||||
public bool? AllowOfflineCacheFallback { get; set; }
|
||||
|
||||
public TimeSpan? OfflineCacheTolerance { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SigningOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string Algorithm { get; set; } = "ed25519";
|
||||
|
||||
public string? KeyPem { get; set; }
|
||||
|
||||
public string? KeyPemFile { get; set; }
|
||||
|
||||
public string? CertificatePem { get; set; }
|
||||
|
||||
public string? CertificatePemFile { get; set; }
|
||||
|
||||
public string? CertificateChainPem { get; set; }
|
||||
|
||||
public string? CertificateChainPemFile { get; set; }
|
||||
|
||||
public int EnvelopeTtlSeconds { get; set; } = 600;
|
||||
}
|
||||
|
||||
public sealed class ApiOptions
|
||||
{
|
||||
public string BasePath { get; set; } = "/api/v1";
|
||||
|
||||
public string ScansSegment { get; set; } = "scans";
|
||||
|
||||
public string ReportsSegment { get; set; } = "reports";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Post-configuration helpers for <see cref="ScannerWebServiceOptions"/>.
|
||||
/// </summary>
|
||||
public static class ScannerWebServiceOptionsPostConfigure
|
||||
{
|
||||
public static void Apply(ScannerWebServiceOptions options, string contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(contentRootPath);
|
||||
|
||||
options.Plugins ??= new ScannerWebServiceOptions.PluginOptions();
|
||||
if (string.IsNullOrWhiteSpace(options.Plugins.Directory))
|
||||
{
|
||||
options.Plugins.Directory = Path.Combine("plugins", "scanner");
|
||||
}
|
||||
|
||||
options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions();
|
||||
var authority = options.Authority;
|
||||
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
|
||||
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
|
||||
{
|
||||
authority.ClientSecret = ReadSecretFile(authority.ClientSecretFile!, contentRootPath);
|
||||
}
|
||||
|
||||
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
|
||||
var artifactStore = options.ArtifactStore;
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.SecretKey)
|
||||
&& !string.IsNullOrWhiteSpace(artifactStore.SecretKeyFile))
|
||||
{
|
||||
artifactStore.SecretKey = ReadSecretFile(artifactStore.SecretKeyFile!, contentRootPath);
|
||||
}
|
||||
|
||||
options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
|
||||
var signing = options.Signing;
|
||||
if (string.IsNullOrWhiteSpace(signing.KeyPem)
|
||||
&& !string.IsNullOrWhiteSpace(signing.KeyPemFile))
|
||||
{
|
||||
signing.KeyPem = ReadAllText(signing.KeyPemFile!, contentRootPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.CertificatePem)
|
||||
&& !string.IsNullOrWhiteSpace(signing.CertificatePemFile))
|
||||
{
|
||||
signing.CertificatePem = ReadAllText(signing.CertificatePemFile!, contentRootPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.CertificateChainPem)
|
||||
&& !string.IsNullOrWhiteSpace(signing.CertificateChainPemFile))
|
||||
{
|
||||
signing.CertificateChainPem = ReadAllText(signing.CertificateChainPemFile!, contentRootPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadSecretFile(string path, string contentRootPath)
|
||||
{
|
||||
var resolvedPath = ResolvePath(path, contentRootPath);
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Secret file '{resolvedPath}' was not found.");
|
||||
}
|
||||
|
||||
var secret = File.ReadAllText(resolvedPath).Trim();
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
throw new InvalidOperationException($"Secret file '{resolvedPath}' is empty.");
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
private static string ReadAllText(string path, string contentRootPath)
|
||||
{
|
||||
var resolvedPath = ResolvePath(path, contentRootPath);
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
throw new InvalidOperationException($"File '{resolvedPath}' was not found.");
|
||||
}
|
||||
|
||||
return File.ReadAllText(resolvedPath);
|
||||
}
|
||||
|
||||
private static string ResolvePath(string path, string contentRootPath)
|
||||
=> Path.IsPathRooted(path)
|
||||
? path
|
||||
: Path.GetFullPath(Path.Combine(contentRootPath, path));
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Validation helpers for <see cref="ScannerWebServiceOptions"/>.
|
||||
/// </summary>
|
||||
public static class ScannerWebServiceOptionsValidator
|
||||
{
|
||||
private static readonly HashSet<string> SupportedStorageDrivers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"mongo"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SupportedQueueDrivers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"redis",
|
||||
"nats",
|
||||
"rabbitmq"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SupportedArtifactDrivers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"minio"
|
||||
};
|
||||
|
||||
public static void Validate(ScannerWebServiceOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.SchemaVersion <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner configuration requires a positive schemaVersion.");
|
||||
}
|
||||
|
||||
options.Storage ??= new ScannerWebServiceOptions.StorageOptions();
|
||||
ValidateStorage(options.Storage);
|
||||
|
||||
options.Queue ??= new ScannerWebServiceOptions.QueueOptions();
|
||||
ValidateQueue(options.Queue);
|
||||
|
||||
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
|
||||
ValidateArtifactStore(options.ArtifactStore);
|
||||
|
||||
options.Features ??= new ScannerWebServiceOptions.FeatureFlagOptions();
|
||||
options.Plugins ??= new ScannerWebServiceOptions.PluginOptions();
|
||||
options.Telemetry ??= new ScannerWebServiceOptions.TelemetryOptions();
|
||||
ValidateTelemetry(options.Telemetry);
|
||||
|
||||
options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions();
|
||||
ValidateAuthority(options.Authority);
|
||||
|
||||
options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
|
||||
ValidateSigning(options.Signing);
|
||||
|
||||
options.Api ??= new ScannerWebServiceOptions.ApiOptions();
|
||||
if (string.IsNullOrWhiteSpace(options.Api.BasePath))
|
||||
{
|
||||
throw new InvalidOperationException("API basePath must be configured.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateStorage(ScannerWebServiceOptions.StorageOptions storage)
|
||||
{
|
||||
if (!SupportedStorageDrivers.Contains(storage.Driver))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'. Supported drivers: mongo.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(storage.Dsn))
|
||||
{
|
||||
throw new InvalidOperationException("Storage DSN must be configured.");
|
||||
}
|
||||
|
||||
if (storage.CommandTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Storage commandTimeoutSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (storage.HealthCheckTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Storage healthCheckTimeoutSeconds must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateQueue(ScannerWebServiceOptions.QueueOptions queue)
|
||||
{
|
||||
if (!SupportedQueueDrivers.Contains(queue.Driver))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported queue driver '{queue.Driver}'. Supported drivers: redis, nats, rabbitmq.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(queue.Dsn))
|
||||
{
|
||||
throw new InvalidOperationException("Queue DSN must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(queue.Namespace))
|
||||
{
|
||||
throw new InvalidOperationException("Queue namespace must be configured.");
|
||||
}
|
||||
|
||||
if (queue.VisibilityTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Queue visibilityTimeoutSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (queue.LeaseHeartbeatSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Queue leaseHeartbeatSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (queue.MaxDeliveryAttempts <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Queue maxDeliveryAttempts must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateArtifactStore(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore)
|
||||
{
|
||||
if (!SupportedArtifactDrivers.Contains(artifactStore.Driver))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported artifact store driver '{artifactStore.Driver}'. Supported drivers: minio.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store endpoint must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.Bucket))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store bucket must be configured.");
|
||||
}
|
||||
|
||||
if (artifactStore.EnableObjectLock && artifactStore.ObjectLockRetentionDays <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store objectLockRetentionDays must be greater than zero when object lock is enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTelemetry(ScannerWebServiceOptions.TelemetryOptions telemetry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telemetry.MinimumLogLevel))
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry minimumLogLevel must be configured.");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogLevel _))
|
||||
{
|
||||
throw new InvalidOperationException($"Telemetry minimumLogLevel '{telemetry.MinimumLogLevel}' is invalid.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint) && !Uri.TryCreate(telemetry.OtlpEndpoint, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry OTLP endpoint must be an absolute URI when specified.");
|
||||
}
|
||||
|
||||
foreach (var attribute in telemetry.ResourceAttributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attribute.Key))
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry resource attribute keys must be non-empty.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var header in telemetry.OtlpHeaders)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(header.Key))
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry OTLP header keys must be non-empty.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAuthority(ScannerWebServiceOptions.AuthorityOptions authority)
|
||||
{
|
||||
authority.Resilience ??= new ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions();
|
||||
NormalizeList(authority.Audiences, toLower: false);
|
||||
NormalizeList(authority.RequiredScopes, toLower: true);
|
||||
NormalizeList(authority.BypassNetworks, toLower: false);
|
||||
NormalizeList(authority.ClientScopes, toLower: true);
|
||||
NormalizeResilience(authority.Resilience);
|
||||
|
||||
if (authority.RequiredScopes.Count == 0)
|
||||
{
|
||||
authority.RequiredScopes.Add(ScannerAuthorityScopes.ScansEnqueue);
|
||||
}
|
||||
|
||||
if (authority.ClientScopes.Count == 0)
|
||||
{
|
||||
foreach (var scope in authority.RequiredScopes)
|
||||
{
|
||||
authority.ClientScopes.Add(scope);
|
||||
}
|
||||
}
|
||||
|
||||
if (authority.BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Authority backchannelTimeoutSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (authority.TokenClockSkewSeconds < 0 || authority.TokenClockSkewSeconds > 300)
|
||||
{
|
||||
throw new InvalidOperationException("Authority tokenClockSkewSeconds must be between 0 and 300 seconds.");
|
||||
}
|
||||
|
||||
if (!authority.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authority.Issuer))
|
||||
{
|
||||
throw new InvalidOperationException("Authority issuer must be configured when authority is enabled.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(authority.Issuer, UriKind.Absolute, out var issuerUri))
|
||||
{
|
||||
throw new InvalidOperationException("Authority issuer must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (authority.RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Authority issuer must use HTTPS when requireHttpsMetadata is enabled.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(authority.MetadataAddress) && !Uri.TryCreate(authority.MetadataAddress, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Authority metadataAddress must be an absolute URI when specified.");
|
||||
}
|
||||
|
||||
if (authority.Audiences.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Authority audiences must include at least one entry when authority is enabled.");
|
||||
}
|
||||
|
||||
if (!authority.AllowAnonymousFallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authority.ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Authority clientId must be configured when anonymous fallback is disabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authority.ClientSecret))
|
||||
{
|
||||
throw new InvalidOperationException("Authority clientSecret must be configured when anonymous fallback is disabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSigning(ScannerWebServiceOptions.SigningOptions signing)
|
||||
{
|
||||
if (signing.EnvelopeTtlSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Signing envelopeTtlSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.KeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Signing keyId must be configured when signing is enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.Algorithm))
|
||||
{
|
||||
throw new InvalidOperationException("Signing algorithm must be configured when signing is enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.KeyPem) && string.IsNullOrWhiteSpace(signing.KeyPemFile))
|
||||
{
|
||||
throw new InvalidOperationException("Signing requires keyPem or keyPemFile when enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, bool toLower)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = values.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var entry = values[i];
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
values.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = toLower ? entry.Trim().ToLowerInvariant() : entry.Trim();
|
||||
if (!seen.Add(normalized))
|
||||
{
|
||||
values.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[i] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeResilience(ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions resilience)
|
||||
{
|
||||
if (resilience.RetryDelays is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var delay in resilience.RetryDelays.ToArray())
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Authority resilience retryDelays must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
245
src/StellaOps.Scanner.WebService/Program.cs
Normal file
245
src/StellaOps.Scanner.WebService/Program.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Extensions;
|
||||
using StellaOps.Scanner.WebService.Hosting;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "SCANNER_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
configurationBuilder.AddScannerYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/scanner.yaml"));
|
||||
};
|
||||
});
|
||||
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
|
||||
var bootstrapOptions = builder.Configuration.BindOptions<ScannerWebServiceOptions>(
|
||||
ScannerWebServiceOptions.SectionName,
|
||||
(opts, _) =>
|
||||
{
|
||||
ScannerWebServiceOptionsPostConfigure.Apply(opts, contentRoot);
|
||||
ScannerWebServiceOptionsValidator.Validate(opts);
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<ScannerWebServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ScannerWebServiceOptions.SectionName))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
ScannerWebServiceOptionsPostConfigure.Apply(options, contentRoot);
|
||||
ScannerWebServiceOptionsValidator.Validate(options);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ServiceStatus>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<ScanProgressStream>();
|
||||
builder.Services.AddSingleton<IScanProgressPublisher>(sp => sp.GetRequiredService<ScanProgressStream>());
|
||||
builder.Services.AddSingleton<IScanProgressReader>(sp => sp.GetRequiredService<ScanProgressStream>());
|
||||
builder.Services.AddSingleton<IScanCoordinator, InMemoryScanCoordinator>();
|
||||
|
||||
var pluginHostOptions = ScannerPluginHostFactory.Build(bootstrapOptions, contentRoot);
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
if (bootstrapOptions.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddStellaOpsAuthClient(clientOptions =>
|
||||
{
|
||||
clientOptions.Authority = bootstrapOptions.Authority.Issuer;
|
||||
clientOptions.ClientId = bootstrapOptions.Authority.ClientId ?? string.Empty;
|
||||
clientOptions.ClientSecret = bootstrapOptions.Authority.ClientSecret;
|
||||
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrapOptions.Authority.BackchannelTimeoutSeconds);
|
||||
|
||||
clientOptions.DefaultScopes.Clear();
|
||||
foreach (var scope in bootstrapOptions.Authority.ClientScopes)
|
||||
{
|
||||
clientOptions.DefaultScopes.Add(scope);
|
||||
}
|
||||
|
||||
var resilience = bootstrapOptions.Authority.Resilience ?? new ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions();
|
||||
if (resilience.EnableRetries.HasValue)
|
||||
{
|
||||
clientOptions.EnableRetries = resilience.EnableRetries.Value;
|
||||
}
|
||||
|
||||
if (resilience.RetryDelays is { Count: > 0 })
|
||||
{
|
||||
clientOptions.RetryDelays.Clear();
|
||||
foreach (var delay in resilience.RetryDelays)
|
||||
{
|
||||
clientOptions.RetryDelays.Add(delay);
|
||||
}
|
||||
}
|
||||
|
||||
if (resilience.AllowOfflineCacheFallback.HasValue)
|
||||
{
|
||||
clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value;
|
||||
}
|
||||
|
||||
if (resilience.OfflineCacheTolerance.HasValue)
|
||||
{
|
||||
clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value;
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = bootstrapOptions.Authority.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(bootstrapOptions.Authority.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(bootstrapOptions.Authority.TokenClockSkewSeconds);
|
||||
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in bootstrapOptions.Authority.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
resourceOptions.RequiredScopes.Clear();
|
||||
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
resourceOptions.BypassNetworks.Clear();
|
||||
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansEnqueue, bootstrapOptions.Authority.RequiredScopes.ToArray());
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansRead, ScannerAuthorityScopes.ScansRead);
|
||||
options.AddStellaOpsScopePolicy(ScannerPolicies.Reports, ScannerAuthorityScopes.ReportsRead);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "Anonymous";
|
||||
options.DefaultChallengeScheme = "Anonymous";
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", _ => { });
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(ScannerPolicies.ScansEnqueue, policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy(ScannerPolicies.ScansRead, policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy(ScannerPolicies.Reports, policy => policy.RequireAssertion(_ => true));
|
||||
});
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var resolvedOptions = app.Services.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
||||
var authorityConfigured = resolvedOptions.Authority.Enabled;
|
||||
if (authorityConfigured && resolvedOptions.Authority.AllowAnonymousFallback)
|
||||
{
|
||||
app.Logger.LogWarning(
|
||||
"Scanner authority authentication is enabled but anonymous fallback remains allowed. Disable fallback before production rollout.");
|
||||
}
|
||||
|
||||
if (resolvedOptions.Telemetry.EnableLogging && resolvedOptions.Telemetry.EnableRequestLogging)
|
||||
{
|
||||
app.UseSerilogRequestLogging(options =>
|
||||
{
|
||||
options.GetLevel = (httpContext, elapsed, exception) =>
|
||||
exception is null ? LogEventLevel.Information : LogEventLevel.Error;
|
||||
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
||||
{
|
||||
diagnosticContext.Set("RequestId", httpContext.TraceIdentifier);
|
||||
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
|
||||
if (Activity.Current is { TraceId: var traceId } && traceId != default)
|
||||
{
|
||||
diagnosticContext.Set("TraceId", traceId.ToString());
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
app.UseExceptionHandler(errorApp =>
|
||||
{
|
||||
errorApp.Run(async context =>
|
||||
{
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
var feature = context.Features.Get<IExceptionHandlerFeature>();
|
||||
var error = feature?.Error;
|
||||
|
||||
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||
};
|
||||
|
||||
var problem = Results.Problem(
|
||||
detail: error?.Message,
|
||||
instance: context.Request.Path,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Unexpected server error",
|
||||
type: "https://stellaops.org/problems/internal-error",
|
||||
extensions: extensions);
|
||||
|
||||
await problem.ExecuteAsync(context).ConfigureAwait(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
app.MapHealthEndpoints();
|
||||
|
||||
var apiGroup = app.MapGroup(resolvedOptions.Api.BasePath);
|
||||
|
||||
if (app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
apiGroup.MapGet("/__auth-probe", () => Results.Ok("ok"))
|
||||
.RequireAuthorization(ScannerPolicies.ScansEnqueue)
|
||||
.WithName("scanner.auth-probe");
|
||||
}
|
||||
|
||||
apiGroup.MapScanEndpoints();
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Security;
|
||||
|
||||
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public AnonymousAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var identity = new ClaimsIdentity(authenticationType: Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Scanner.WebService.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical scope names consumed by the Scanner WebService.
|
||||
/// </summary>
|
||||
internal static class ScannerAuthorityScopes
|
||||
{
|
||||
public const string ScansEnqueue = "scanner.scans.enqueue";
|
||||
public const string ScansRead = "scanner.scans.read";
|
||||
public const string ReportsRead = "scanner.reports.read";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Scanner.WebService.Security;
|
||||
|
||||
internal static class ScannerPolicies
|
||||
{
|
||||
public const string ScansEnqueue = "scanner.api";
|
||||
public const string ScansRead = "scanner.scans.read";
|
||||
public const string Reports = "scanner.reports";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IScanCoordinator
|
||||
{
|
||||
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Utilities;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
private sealed record ScanEntry(ScanSnapshot Snapshot);
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IScanProgressPublisher progressPublisher;
|
||||
|
||||
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.progressPublisher = progressPublisher ?? throw new ArgumentNullException(nameof(progressPublisher));
|
||||
}
|
||||
|
||||
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(submission);
|
||||
|
||||
var normalizedTarget = submission.Target.Normalize();
|
||||
var metadata = submission.Metadata ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var scanId = ScanIdGenerator.Create(normalizedTarget, submission.Force, submission.ClientRequestId, metadata);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var eventData = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["force"] = submission.Force,
|
||||
};
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
eventData[$"meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
|
||||
ScanEntry entry = scans.AddOrUpdate(
|
||||
scanId.Value,
|
||||
_ => new ScanEntry(new ScanSnapshot(
|
||||
scanId,
|
||||
normalizedTarget,
|
||||
ScanStatus.Pending,
|
||||
now,
|
||||
now,
|
||||
null)),
|
||||
(_, existing) =>
|
||||
{
|
||||
if (submission.Force)
|
||||
{
|
||||
var snapshot = existing.Snapshot with
|
||||
{
|
||||
Status = ScanStatus.Pending,
|
||||
UpdatedAt = now,
|
||||
FailureReason = null
|
||||
};
|
||||
return new ScanEntry(snapshot);
|
||||
}
|
||||
|
||||
return existing;
|
||||
});
|
||||
|
||||
var created = entry.Snapshot.CreatedAt == now;
|
||||
var state = entry.Snapshot.Status.ToString();
|
||||
progressPublisher.Publish(scanId, state, created ? "queued" : "requeued", eventData);
|
||||
return ValueTask.FromResult(new ScanSubmissionResult(entry.Snapshot, created));
|
||||
}
|
||||
|
||||
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (scans.TryGetValue(scanId.Value, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
}
|
||||
136
src/StellaOps.Scanner.WebService/Services/ScanProgressStream.cs
Normal file
136
src/StellaOps.Scanner.WebService/Services/ScanProgressStream.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IScanProgressPublisher
|
||||
{
|
||||
ScanProgressEvent Publish(
|
||||
ScanId scanId,
|
||||
string state,
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, object?>? data = null,
|
||||
string? correlationId = null);
|
||||
}
|
||||
|
||||
public interface IScanProgressReader
|
||||
{
|
||||
bool Exists(ScanId scanId);
|
||||
|
||||
IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(ScanId scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressReader
|
||||
{
|
||||
private sealed class ProgressChannel
|
||||
{
|
||||
private readonly List<ScanProgressEvent> history = new();
|
||||
private readonly Channel<ScanProgressEvent> channel = Channel.CreateUnbounded<ScanProgressEvent>(new UnboundedChannelOptions
|
||||
{
|
||||
AllowSynchronousContinuations = true,
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public int Sequence { get; private set; }
|
||||
|
||||
public ScanProgressEvent Append(ScanProgressEvent progressEvent)
|
||||
{
|
||||
history.Add(progressEvent);
|
||||
channel.Writer.TryWrite(progressEvent);
|
||||
return progressEvent;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ScanProgressEvent> Snapshot()
|
||||
{
|
||||
return history.Count == 0
|
||||
? Array.Empty<ScanProgressEvent>()
|
||||
: history.ToArray();
|
||||
}
|
||||
|
||||
public ChannelReader<ScanProgressEvent> Reader => channel.Reader;
|
||||
|
||||
public int NextSequence() => ++Sequence;
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyData = new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private readonly ConcurrentDictionary<string, ProgressChannel> channels = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public ScanProgressStream(TimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public bool Exists(ScanId scanId)
|
||||
=> channels.ContainsKey(scanId.Value);
|
||||
|
||||
public ScanProgressEvent Publish(
|
||||
ScanId scanId,
|
||||
string state,
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, object?>? data = null,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var channel = channels.GetOrAdd(scanId.Value, _ => new ProgressChannel());
|
||||
|
||||
ScanProgressEvent progressEvent;
|
||||
lock (channel)
|
||||
{
|
||||
var sequence = channel.NextSequence();
|
||||
var correlation = correlationId ?? $"{scanId.Value}:{sequence:D4}";
|
||||
var payload = data is null || data.Count == 0
|
||||
? EmptyData
|
||||
: new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(data, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
progressEvent = new ScanProgressEvent(
|
||||
scanId,
|
||||
sequence,
|
||||
timeProvider.GetUtcNow(),
|
||||
state,
|
||||
message,
|
||||
correlation,
|
||||
payload);
|
||||
|
||||
channel.Append(progressEvent);
|
||||
}
|
||||
|
||||
return progressEvent;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(
|
||||
ScanId scanId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (!channels.TryGetValue(scanId.Value, out var channel))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
IReadOnlyList<ScanProgressEvent> snapshot;
|
||||
lock (channel)
|
||||
{
|
||||
snapshot = channel.Snapshot();
|
||||
}
|
||||
|
||||
foreach (var progressEvent in snapshot)
|
||||
{
|
||||
yield return progressEvent;
|
||||
}
|
||||
|
||||
var reader = channel.Reader;
|
||||
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (reader.TryRead(out var progressEvent))
|
||||
{
|
||||
yield return progressEvent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Scanner.WebService</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCANNER-WEB-09-101 | TODO | Scanner WebService Guild | SCANNER-CORE-09-501 | Stand up minimal API host with Authority OpTok + DPoP enforcement, health/ready endpoints, and restart-time plug-in loader per architecture §1, §4. | Host boots with configuration validation, `/healthz` and `/readyz` return 200, Authority middleware enforced in integration tests. |
|
||||
| SCANNER-WEB-09-102 | TODO | Scanner WebService Guild | SCANNER-WEB-09-101, SCANNER-QUEUE-09-401 | Implement `/api/v1/scans` submission/status endpoints with deterministic IDs, validation, and cancellation tokens. | Contract documented, e2e test posts scan request and retrieves status, cancellation token honoured. |
|
||||
| SCANNER-WEB-09-103 | TODO | Scanner WebService Guild | SCANNER-WEB-09-102, SCANNER-CORE-09-502 | Emit scan progress via SSE/JSONL with correlation IDs and deterministic timestamps; document API reference. | Streaming endpoint verified in tests, timestamps formatted ISO-8601 UTC, docs updated in `docs/09_API_CLI_REFERENCE.md`. |
|
||||
| SCANNER-WEB-09-104 | TODO | Scanner WebService Guild | SCANNER-STORAGE-09-301, SCANNER-QUEUE-09-401 | Bind configuration for Mongo, MinIO, queue, feature flags; add startup diagnostics and fail-fast policy for missing deps. | Misconfiguration fails fast with actionable errors, configuration bound tests pass, diagnostics logged with correlation IDs. |
|
||||
| SCANNER-POLICY-09-105 | TODO | Scanner WebService Guild | POLICY-CORE-09-001 | Integrate policy schema loader + diagnostics + OpenAPI (YAML ignore rules, VEX include/exclude, vendor precedence). | Policy endpoints documented; validation surfaces actionable errors; OpenAPI schema published. |
|
||||
| SCANNER-WEB-09-101 | DONE (2025-10-18) | Scanner WebService Guild | SCANNER-CORE-09-501 | Stand up minimal API host with Authority OpTok + DPoP enforcement, health/ready endpoints, and restart-time plug-in loader per architecture §1, §4. | Host boots with configuration validation, `/healthz` and `/readyz` return 200, Authority middleware enforced in integration tests. |
|
||||
| SCANNER-WEB-09-102 | DONE (2025-10-18) | Scanner WebService Guild | SCANNER-WEB-09-101, SCANNER-QUEUE-09-401 | Implement `/api/v1/scans` submission/status endpoints with deterministic IDs, validation, and cancellation tokens. | Contract documented, e2e test posts scan request and retrieves status, cancellation token honoured. |
|
||||
| SCANNER-WEB-09-103 | DOING | Scanner WebService Guild | SCANNER-WEB-09-102, SCANNER-CORE-09-502 | Emit scan progress via SSE/JSONL with correlation IDs and deterministic timestamps; document API reference. | Streaming endpoint verified in tests, timestamps formatted ISO-8601 UTC, docs updated in `docs/09_API_CLI_REFERENCE.md`. |
|
||||
| SCANNER-WEB-09-104 | DONE (2025-10-19) | Scanner WebService Guild | SCANNER-STORAGE-09-301, SCANNER-QUEUE-09-401 | Bind configuration for Mongo, MinIO, queue, feature flags; add startup diagnostics and fail-fast policy for missing deps. | Misconfiguration fails fast with actionable errors, configuration bound tests pass, diagnostics logged with correlation IDs. |
|
||||
| SCANNER-POLICY-09-105 | DOING | Scanner WebService Guild | POLICY-CORE-09-001 | Integrate policy schema loader + diagnostics + OpenAPI (YAML ignore rules, VEX include/exclude, vendor precedence). | Policy endpoints documented; validation surfaces actionable errors; OpenAPI schema published. |
|
||||
| SCANNER-POLICY-09-106 | TODO | Scanner WebService Guild | POLICY-CORE-09-002, SCANNER-POLICY-09-105 | `/reports` verdict assembly (Feedser/Vexer/Policy merge) + signed response envelope. | Aggregated report includes policy metadata; integration test verifies signed response; docs updated. |
|
||||
| SCANNER-POLICY-09-107 | TODO | Scanner WebService Guild | POLICY-CORE-09-005, SCANNER-POLICY-09-106 | Surface score inputs, config version, and `quietedBy` provenance in `/reports` response and signed payload; document schema changes. | `/reports` JSON + DSSE contain score, reachability, sourceTrust, confidenceBand, quiet provenance; contract tests updated; docs refreshed. |
|
||||
| SCANNER-RUNTIME-12-301 | TODO | Scanner WebService Guild | ZASTAVA-CORE-12-201 | Implement `/runtime/events` ingestion endpoint with validation, batching, and storage hooks per Zastava contract. | Observer fixtures POST events, data persisted and acked; invalid payloads rejected with deterministic errors. |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Utilities;
|
||||
|
||||
internal static class ScanIdGenerator
|
||||
{
|
||||
public static ScanId Create(
|
||||
ScanTarget target,
|
||||
bool force,
|
||||
string? clientRequestId,
|
||||
IReadOnlyDictionary<string, string>? metadata)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append('|');
|
||||
builder.Append(target.Reference?.Trim().ToLowerInvariant() ?? string.Empty);
|
||||
builder.Append('|');
|
||||
builder.Append(target.Digest?.Trim().ToLowerInvariant() ?? string.Empty);
|
||||
builder.Append("|force:");
|
||||
builder.Append(force ? '1' : '0');
|
||||
builder.Append("|client:");
|
||||
builder.Append(clientRequestId?.Trim().ToLowerInvariant() ?? string.Empty);
|
||||
|
||||
if (metadata is not null && metadata.Count > 0)
|
||||
{
|
||||
foreach (var pair in metadata.OrderBy(static entry => entry.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var key = pair.Key?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
var value = pair.Value?.Trim() ?? string.Empty;
|
||||
builder.Append('|');
|
||||
builder.Append(key);
|
||||
builder.Append('=');
|
||||
builder.Append(value);
|
||||
}
|
||||
}
|
||||
|
||||
var canonical = builder.ToString();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
|
||||
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
var trimmed = hex.Length > 40 ? hex[..40] : hex;
|
||||
return new ScanId(trimmed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user