Add tests and implement StubBearer authentication for Signer endpoints

- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints.
- Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication.
- Developed ConcelierExporterClient for managing Trivy DB settings and export operations.
- Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering.
- Implemented styles and HTML structure for Trivy DB settings page.
- Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
This commit is contained in:
master
2025-10-21 09:37:07 +03:00
parent d6cb41dd51
commit 48f3071e2a
298 changed files with 20490 additions and 5751 deletions

View File

@@ -2,8 +2,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";
}
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";
public const string RateLimited = "https://stellaops.org/problems/rate-limit";
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record RuntimeEventsIngestRequestDto
{
[JsonPropertyName("batchId")]
public string? BatchId { get; init; }
[JsonPropertyName("events")]
public IReadOnlyList<RuntimeEventEnvelope> Events { get; init; } = Array.Empty<RuntimeEventEnvelope>();
}
public sealed record RuntimeEventsIngestResponseDto
{
[JsonPropertyName("accepted")]
public int Accepted { get; init; }
[JsonPropertyName("duplicates")]
public int Duplicates { get; init; }
}

View File

@@ -0,0 +1,75 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record RuntimePolicyRequestDto
{
[JsonPropertyName("namespace")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Namespace { get; init; }
[JsonPropertyName("labels")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IDictionary<string, string>? Labels { get; init; }
[JsonPropertyName("images")]
public IReadOnlyList<string> Images { get; init; } = Array.Empty<string>();
}
public sealed record RuntimePolicyResponseDto
{
[JsonPropertyName("ttlSeconds")]
public int TtlSeconds { get; init; }
[JsonPropertyName("expiresAtUtc")]
public DateTimeOffset ExpiresAtUtc { get; init; }
[JsonPropertyName("policyRevision")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyRevision { get; init; }
[JsonPropertyName("results")]
public IReadOnlyDictionary<string, RuntimePolicyImageResponseDto> Results { get; init; } = new Dictionary<string, RuntimePolicyImageResponseDto>(StringComparer.Ordinal);
}
public sealed record RuntimePolicyImageResponseDto
{
[JsonPropertyName("policyVerdict")]
public string PolicyVerdict { get; init; } = "unknown";
[JsonPropertyName("signed")]
public bool Signed { get; init; }
[JsonPropertyName("hasSbomReferrers")]
public bool HasSbomReferrers { get; init; }
[JsonPropertyName("hasSbom")]
public bool HasSbomLegacy { get; init; }
[JsonPropertyName("reasons")]
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
[JsonPropertyName("rekor")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public RuntimePolicyRekorDto? Rekor { get; init; }
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IDictionary<string, object?>? Metadata { get; init; }
}
public sealed record RuntimePolicyRekorDto
{
[JsonPropertyName("uuid")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Uuid { get; init; }
[JsonPropertyName("url")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Url { get; init; }
[JsonPropertyName("verified")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Verified { get; init; }
}

View File

@@ -1,8 +1,10 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
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;
@@ -51,18 +53,30 @@ internal static class PolicyEndpoints
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("/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)
{
@@ -152,9 +166,97 @@ internal static class PolicyEndpoints
var domainRequest = PolicyDtoMapper.ToDomain(request);
var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var payload = PolicyDtoMapper.ToDto(response);
return Json(payload);
}
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)
{
@@ -167,9 +269,53 @@ internal static class PolicyEndpoints
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 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
};
}
IDictionary<string, object?>? metadata = null;
if (decision.Metadata is not null && decision.Metadata.Count > 0)
{
metadata = new Dictionary<string, object?>(decision.Metadata, StringComparer.OrdinalIgnoreCase);
}
results[pair.Key] = new RuntimePolicyImageResponseDto
{
PolicyVerdict = decision.PolicyVerdict,
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
HasSbomLegacy = decision.HasSbomReferrers,
Reasons = decision.Reasons.ToArray(),
Rekor = rekor,
Metadata = metadata
};
}
return new RuntimePolicyResponseDto
{
TtlSeconds = evaluation.TtlSeconds,
ExpiresAtUtc = evaluation.ExpiresAtUtc,
PolicyRevision = evaluation.PolicyRevision,
Results = results
};
}
}

View File

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

View File

@@ -55,15 +55,20 @@ public sealed class ScannerWebServiceOptions
/// </summary>
public SigningOptions Signing { get; set; } = new();
/// <summary>
/// API-specific settings such as base path.
/// </summary>
public ApiOptions Api { get; set; } = new();
/// <summary>
/// Platform event emission settings.
/// </summary>
public EventsOptions Events { get; set; } = new();
/// <summary>
/// API-specific settings such as base path.
/// </summary>
public ApiOptions Api { get; set; } = new();
/// <summary>
/// Platform event emission settings.
/// </summary>
public EventsOptions Events { get; set; } = new();
/// <summary>
/// Runtime ingestion configuration.
/// </summary>
public RuntimeOptions Runtime { get; set; } = new();
public sealed class StorageOptions
{
@@ -236,20 +241,22 @@ public sealed class ScannerWebServiceOptions
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";
public string PolicySegment { get; set; } = "policy";
}
public sealed class EventsOptions
{
public bool Enabled { get; set; }
public sealed class ApiOptions
{
public string BasePath { get; set; } = "/api/v1";
public string ScansSegment { get; set; } = "scans";
public string ReportsSegment { get; set; } = "reports";
public string PolicySegment { get; set; } = "policy";
public string RuntimeSegment { get; set; } = "runtime";
}
public sealed class EventsOptions
{
public bool Enabled { get; set; }
public string Driver { get; set; } = "redis";
@@ -257,10 +264,29 @@ public sealed class ScannerWebServiceOptions
public string Stream { get; set; } = "stella.events";
public double PublishTimeoutSeconds { get; set; } = 5;
public long MaxStreamLength { get; set; } = 10000;
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}
public double PublishTimeoutSeconds { get; set; } = 5;
public long MaxStreamLength { get; set; } = 10000;
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed class RuntimeOptions
{
public int MaxBatchSize { get; set; } = 256;
public int MaxPayloadBytes { get; set; } = 1 * 1024 * 1024;
public int EventTtlDays { get; set; } = 45;
public double PerNodeEventsPerSecond { get; set; } = 50;
public int PerNodeBurst { get; set; } = 200;
public double PerTenantEventsPerSecond { get; set; } = 200;
public int PerTenantBurst { get; set; } = 1000;
public int PolicyCacheTtlSeconds { get; set; } = 300;
}
}

View File

@@ -70,14 +70,16 @@ public static class ScannerWebServiceOptionsPostConfigure
eventsOptions.Stream = "stella.events";
}
if (string.IsNullOrWhiteSpace(eventsOptions.Dsn)
&& string.Equals(options.Queue?.Driver, "redis", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(options.Queue?.Dsn))
{
eventsOptions.Dsn = options.Queue!.Dsn;
}
}
if (string.IsNullOrWhiteSpace(eventsOptions.Dsn)
&& string.Equals(options.Queue?.Driver, "redis", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(options.Queue?.Dsn))
{
eventsOptions.Dsn = options.Queue!.Dsn;
}
options.Runtime ??= new ScannerWebServiceOptions.RuntimeOptions();
}
private static string ReadSecretFile(string path, string contentRootPath)
{

View File

@@ -78,14 +78,22 @@ public static class ScannerWebServiceOptionsValidator
throw new InvalidOperationException("API reportsSegment must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.PolicySegment))
{
throw new InvalidOperationException("API policySegment must be configured.");
}
options.Events ??= new ScannerWebServiceOptions.EventsOptions();
ValidateEvents(options.Events);
}
if (string.IsNullOrWhiteSpace(options.Api.PolicySegment))
{
throw new InvalidOperationException("API policySegment must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.RuntimeSegment))
{
throw new InvalidOperationException("API runtimeSegment must be configured.");
}
options.Events ??= new ScannerWebServiceOptions.EventsOptions();
ValidateEvents(options.Events);
options.Runtime ??= new ScannerWebServiceOptions.RuntimeOptions();
ValidateRuntime(options.Runtime);
}
private static void ValidateStorage(ScannerWebServiceOptions.StorageOptions storage)
{
@@ -199,7 +207,7 @@ public static class ScannerWebServiceOptionsValidator
}
}
private static void ValidateTelemetry(ScannerWebServiceOptions.TelemetryOptions telemetry)
private static void ValidateTelemetry(ScannerWebServiceOptions.TelemetryOptions telemetry)
{
if (string.IsNullOrWhiteSpace(telemetry.MinimumLogLevel))
{
@@ -231,9 +239,9 @@ public static class ScannerWebServiceOptionsValidator
throw new InvalidOperationException("Telemetry OTLP header keys must be non-empty.");
}
}
}
private static void ValidateAuthority(ScannerWebServiceOptions.AuthorityOptions authority)
}
private static void ValidateAuthority(ScannerWebServiceOptions.AuthorityOptions authority)
{
authority.Resilience ??= new ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions();
NormalizeList(authority.Audiences, toLower: false);
@@ -384,5 +392,48 @@ public static class ScannerWebServiceOptionsValidator
{
throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero.");
}
}
}
}
private static void ValidateRuntime(ScannerWebServiceOptions.RuntimeOptions runtime)
{
if (runtime.MaxBatchSize <= 0)
{
throw new InvalidOperationException("Runtime maxBatchSize must be greater than zero.");
}
if (runtime.MaxPayloadBytes <= 0)
{
throw new InvalidOperationException("Runtime maxPayloadBytes must be greater than zero.");
}
if (runtime.EventTtlDays <= 0)
{
throw new InvalidOperationException("Runtime eventTtlDays must be greater than zero.");
}
if (runtime.PerNodeEventsPerSecond <= 0)
{
throw new InvalidOperationException("Runtime perNodeEventsPerSecond must be greater than zero.");
}
if (runtime.PerNodeBurst <= 0)
{
throw new InvalidOperationException("Runtime perNodeBurst must be greater than zero.");
}
if (runtime.PerTenantEventsPerSecond <= 0)
{
throw new InvalidOperationException("Runtime perTenantEventsPerSecond must be greater than zero.");
}
if (runtime.PerTenantBurst <= 0)
{
throw new InvalidOperationException("Runtime perTenantBurst must be greater than zero.");
}
if (runtime.PolicyCacheTtlSeconds <= 0)
{
throw new InvalidOperationException("Runtime policyCacheTtlSeconds must be greater than zero.");
}
}
}

View File

@@ -3,29 +3,32 @@ 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.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using StellaOps.Policy;
using StellaOps.Scanner.Cache;
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;
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.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using StellaOps.Policy;
using StellaOps.Scanner.Cache;
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;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Extensions;
using StellaOps.Scanner.Storage.Mongo;
var builder = WebApplication.CreateBuilder(args);
@@ -67,11 +70,11 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
.WriteTo.Console();
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScannerCache(builder.Configuration);
builder.Services.AddSingleton<ServiceStatus>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ScanProgressStream>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScannerCache(builder.Configuration);
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>();
@@ -81,17 +84,54 @@ builder.Services.AddSingleton<PolicySnapshotStore>();
builder.Services.AddSingleton<PolicyPreviewService>();
builder.Services.AddStellaOpsCrypto();
builder.Services.AddBouncyCastleEd25519Provider();
builder.Services.AddSingleton<IReportSigner, ReportSigner>();
if (bootstrapOptions.Events is { Enabled: true } eventsOptions
&& string.Equals(eventsOptions.Driver, "redis", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddSingleton<IPlatformEventPublisher, RedisPlatformEventPublisher>();
}
else
{
builder.Services.AddSingleton<IPlatformEventPublisher, NullPlatformEventPublisher>();
}
builder.Services.AddSingleton<IReportEventDispatcher, ReportEventDispatcher>();
builder.Services.AddSingleton<IReportSigner, ReportSigner>();
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
if (bootstrapOptions.Events is { Enabled: true } eventsOptions
&& string.Equals(eventsOptions.Driver, "redis", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddSingleton<IPlatformEventPublisher, RedisPlatformEventPublisher>();
}
else
{
builder.Services.AddSingleton<IPlatformEventPublisher, NullPlatformEventPublisher>();
}
builder.Services.AddSingleton<IReportEventDispatcher, ReportEventDispatcher>();
builder.Services.AddScannerStorage(storageOptions =>
{
storageOptions.Mongo.ConnectionString = bootstrapOptions.Storage.Dsn;
if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.Database))
{
storageOptions.Mongo.DatabaseName = bootstrapOptions.Storage.Database;
}
storageOptions.Mongo.CommandTimeout = TimeSpan.FromSeconds(bootstrapOptions.Storage.CommandTimeoutSeconds);
storageOptions.Mongo.UseMajorityReadConcern = true;
storageOptions.Mongo.UseMajorityWriteConcern = true;
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Endpoint))
{
storageOptions.ObjectStore.ServiceUrl = bootstrapOptions.ArtifactStore.Endpoint;
}
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Bucket))
{
storageOptions.ObjectStore.BucketName = bootstrapOptions.ArtifactStore.Bucket;
}
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Region))
{
storageOptions.ObjectStore.Region = bootstrapOptions.ArtifactStore.Region;
}
storageOptions.ObjectStore.EnableObjectLock = bootstrapOptions.ArtifactStore.EnableObjectLock;
storageOptions.ObjectStore.ForcePathStyle = true;
storageOptions.ObjectStore.ComplianceRetention = bootstrapOptions.ArtifactStore.EnableObjectLock
? TimeSpan.FromDays(Math.Max(1, bootstrapOptions.ArtifactStore.ObjectLockRetentionDays))
: null;
});
builder.Services.AddSingleton<RuntimeEventRateLimiter>();
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
builder.Services.AddSingleton<IRuntimePolicyService, RuntimePolicyService>();
var pluginHostOptions = ScannerPluginHostFactory.Build(bootstrapOptions, contentRoot);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
@@ -171,9 +211,10 @@ if (bootstrapOptions.Authority.Enabled)
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansEnqueue, bootstrapOptions.Authority.RequiredScopes.ToArray());
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansRead, ScannerAuthorityScopes.ScansRead);
options.AddStellaOpsScopePolicy(ScannerPolicies.Reports, ScannerAuthorityScopes.ReportsRead);
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansEnqueue, bootstrapOptions.Authority.RequiredScopes.ToArray());
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansRead, ScannerAuthorityScopes.ScansRead);
options.AddStellaOpsScopePolicy(ScannerPolicies.Reports, ScannerAuthorityScopes.ReportsRead);
options.AddStellaOpsScopePolicy(ScannerPolicies.RuntimeIngest, ScannerAuthorityScopes.RuntimeIngest);
});
}
else
@@ -185,23 +226,30 @@ else
})
.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));
});
}
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));
options.AddPolicy(ScannerPolicies.RuntimeIngest, 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.");
}
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.");
}
using (var scope = app.Services.CreateScope())
{
var bootstrapper = scope.ServiceProvider.GetRequiredService<MongoBootstrapper>();
await bootstrapper.InitializeAsync(CancellationToken.None).ConfigureAwait(false);
}
if (resolvedOptions.Telemetry.EnableLogging && resolvedOptions.Telemetry.EnableRequestLogging)
{
@@ -263,14 +311,15 @@ if (app.Environment.IsEnvironment("Testing"))
.WithName("scanner.auth-probe");
}
apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
if (resolvedOptions.Features.EnablePolicyPreview)
{
apiGroup.MapPolicyEndpoints(resolvedOptions.Api.PolicySegment);
}
apiGroup.MapReportEndpoints(resolvedOptions.Api.ReportsSegment);
apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
if (resolvedOptions.Features.EnablePolicyPreview)
{
apiGroup.MapPolicyEndpoints(resolvedOptions.Api.PolicySegment);
}
apiGroup.MapReportEndpoints(resolvedOptions.Api.ReportsSegment);
apiGroup.MapRuntimeEndpoints(resolvedOptions.Api.RuntimeSegment);
app.MapOpenApiIfAvailable();
await app.RunAsync().ConfigureAwait(false);

View File

@@ -5,7 +5,8 @@ namespace StellaOps.Scanner.WebService.Security;
/// </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";
}
public const string ScansEnqueue = "scanner.scans.enqueue";
public const string ScansRead = "scanner.scans.read";
public const string ReportsRead = "scanner.reports.read";
public const string RuntimeIngest = "scanner.runtime.ingest";
}

View File

@@ -2,7 +2,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";
}
public const string ScansEnqueue = "scanner.api";
public const string ScansRead = "scanner.scans.read";
public const string Reports = "scanner.reports";
public const string RuntimeIngest = "scanner.runtime.ingest";
}

View File

@@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Abstraction for creating Redis connections so publishers can be tested without real infrastructure.
/// </summary>
internal interface IRedisConnectionFactory
{
ValueTask<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,19 @@
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Production Redis connection factory bridging to <see cref="ConnectionMultiplexer"/>.
/// </summary>
internal sealed class RedisConnectionFactory : IRedisConnectionFactory
{
public async ValueTask<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
var connectTask = ConnectionMultiplexer.ConnectAsync(options);
var connection = await connectTask.WaitAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
@@ -13,7 +14,8 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
{
private readonly ScannerWebServiceOptions.EventsOptions _options;
private readonly ILogger<RedisPlatformEventPublisher> _logger;
private readonly TimeSpan _publishTimeout;
private readonly IRedisConnectionFactory _connectionFactory;
private readonly TimeSpan _publishTimeout;
private readonly string _streamKey;
private readonly long? _maxStreamLength;
@@ -22,12 +24,14 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
private bool _disposed;
public RedisPlatformEventPublisher(
IOptions<ScannerWebServiceOptions> options,
ILogger<RedisPlatformEventPublisher> logger)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value.Events ?? throw new InvalidOperationException("Events options are required when redis publisher is registered.");
IOptions<ScannerWebServiceOptions> options,
IRedisConnectionFactory connectionFactory,
ILogger<RedisPlatformEventPublisher> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(connectionFactory);
_options = options.Value.Events ?? throw new InvalidOperationException("Events options are required when redis publisher is registered.");
if (!_options.Enabled)
{
throw new InvalidOperationException("RedisPlatformEventPublisher requires events emission to be enabled.");
@@ -37,11 +41,12 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
{
throw new InvalidOperationException($"RedisPlatformEventPublisher cannot be used with driver '{_options.Driver}'.");
}
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_streamKey = string.IsNullOrWhiteSpace(_options.Stream) ? "stella.events" : _options.Stream;
_publishTimeout = TimeSpan.FromSeconds(_options.PublishTimeoutSeconds <= 0 ? 5 : _options.PublishTimeoutSeconds);
_maxStreamLength = _options.MaxStreamLength > 0 ? _options.MaxStreamLength : null;
_connectionFactory = connectionFactory;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_streamKey = string.IsNullOrWhiteSpace(_options.Stream) ? "stella.events" : _options.Stream;
_publishTimeout = TimeSpan.FromSeconds(_options.PublishTimeoutSeconds <= 0 ? 5 : _options.PublishTimeoutSeconds);
_maxStreamLength = _options.MaxStreamLength > 0 ? _options.MaxStreamLength : null;
}
public async Task PublishAsync(NotifyEvent @event, CancellationToken cancellationToken = default)
@@ -108,11 +113,11 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
config.Ssl = ssl;
}
_connection = await ConnectionMultiplexer.ConnectAsync(config).WaitAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Connected Redis platform event publisher to stream {Stream}.", _streamKey);
}
}
finally
_connection = await _connectionFactory.ConnectAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Connected Redis platform event publisher to stream {Stream}.", _streamKey);
}
}
finally
{
_connectionGate.Release();
}

View File

@@ -0,0 +1,151 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text;
using MongoDB.Bson;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal interface IRuntimeEventIngestionService
{
Task<RuntimeEventIngestionResult> IngestAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
string? batchId,
CancellationToken cancellationToken);
}
internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionService
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly RuntimeEventRepository _repository;
private readonly RuntimeEventRateLimiter _rateLimiter;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RuntimeEventIngestionService> _logger;
public RuntimeEventIngestionService(
RuntimeEventRepository repository,
RuntimeEventRateLimiter rateLimiter,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<RuntimeEventIngestionService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<RuntimeEventIngestionResult> IngestAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
string? batchId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelopes);
if (envelopes.Count == 0)
{
return RuntimeEventIngestionResult.Empty;
}
var rateDecision = _rateLimiter.Evaluate(envelopes);
if (!rateDecision.Allowed)
{
_logger.LogWarning(
"Runtime event batch rejected due to rate limit ({Scope}={Key}, retryAfter={RetryAfter})",
rateDecision.Scope,
rateDecision.Key,
rateDecision.RetryAfter);
return RuntimeEventIngestionResult.RateLimited(rateDecision.Scope, rateDecision.Key, rateDecision.RetryAfter);
}
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var receivedAt = _timeProvider.GetUtcNow().UtcDateTime;
var expiresAt = receivedAt.AddDays(options.EventTtlDays);
var documents = new List<RuntimeEventDocument>(envelopes.Count);
var totalPayloadBytes = 0;
foreach (var envelope in envelopes)
{
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, SerializerOptions);
totalPayloadBytes += payloadBytes.Length;
if (totalPayloadBytes > options.MaxPayloadBytes)
{
_logger.LogWarning(
"Runtime event batch exceeds payload budget ({PayloadBytes} > {MaxPayloadBytes})",
totalPayloadBytes,
options.MaxPayloadBytes);
return RuntimeEventIngestionResult.PayloadTooLarge(totalPayloadBytes, options.MaxPayloadBytes);
}
var payloadDocument = BsonDocument.Parse(Encoding.UTF8.GetString(payloadBytes));
var runtimeEvent = envelope.Event;
var document = new RuntimeEventDocument
{
EventId = runtimeEvent.EventId,
SchemaVersion = envelope.SchemaVersion,
Tenant = runtimeEvent.Tenant,
Node = runtimeEvent.Node,
Kind = runtimeEvent.Kind.ToString(),
When = runtimeEvent.When.UtcDateTime,
ReceivedAt = receivedAt,
ExpiresAt = expiresAt,
Platform = runtimeEvent.Workload.Platform,
Namespace = runtimeEvent.Workload.Namespace,
Pod = runtimeEvent.Workload.Pod,
Container = runtimeEvent.Workload.Container,
ContainerId = runtimeEvent.Workload.ContainerId,
ImageRef = runtimeEvent.Workload.ImageRef,
Engine = runtimeEvent.Runtime.Engine,
EngineVersion = runtimeEvent.Runtime.Version,
BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest,
ImageSigned = runtimeEvent.Posture?.ImageSigned,
SbomReferrer = runtimeEvent.Posture?.SbomReferrer,
Payload = payloadDocument
};
documents.Add(document);
}
var insertResult = await _repository.InsertAsync(documents, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Runtime ingestion batch processed (batchId={BatchId}, accepted={Accepted}, duplicates={Duplicates}, payloadBytes={PayloadBytes})",
batchId,
insertResult.InsertedCount,
insertResult.DuplicateCount,
totalPayloadBytes);
return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes);
}
}
internal readonly record struct RuntimeEventIngestionResult(
int Accepted,
int Duplicates,
bool IsRateLimited,
string? RateLimitedScope,
string? RateLimitedKey,
TimeSpan RetryAfter,
bool IsPayloadTooLarge,
int PayloadBytes,
int PayloadLimit)
{
public static RuntimeEventIngestionResult Empty => new(0, 0, false, null, null, TimeSpan.Zero, false, 0, 0);
public static RuntimeEventIngestionResult RateLimited(string? scope, string? key, TimeSpan retryAfter)
=> new(0, 0, true, scope, key, retryAfter, false, 0, 0);
public static RuntimeEventIngestionResult PayloadTooLarge(int payloadBytes, int payloadLimit)
=> new(0, 0, false, null, null, TimeSpan.Zero, true, payloadBytes, payloadLimit);
public static RuntimeEventIngestionResult Success(int accepted, int duplicates, int payloadBytes)
=> new(accepted, duplicates, false, null, null, TimeSpan.Zero, false, payloadBytes, 0);
}

View File

@@ -0,0 +1,173 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class RuntimeEventRateLimiter
{
private readonly ConcurrentDictionary<string, TokenBucket> _tenantBuckets = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, TokenBucket> _nodeBuckets = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
public RuntimeEventRateLimiter(IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor, TimeProvider timeProvider)
{
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public RateLimitDecision Evaluate(IReadOnlyList<RuntimeEventEnvelope> envelopes)
{
ArgumentNullException.ThrowIfNull(envelopes);
if (envelopes.Count == 0)
{
return RateLimitDecision.Success;
}
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var now = _timeProvider.GetUtcNow();
var tenantCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var nodeCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var envelope in envelopes)
{
var tenant = envelope.Event.Tenant;
var node = envelope.Event.Node;
if (tenantCounts.TryGetValue(tenant, out var tenantCount))
{
tenantCounts[tenant] = tenantCount + 1;
}
else
{
tenantCounts[tenant] = 1;
}
var nodeKey = $"{tenant}|{node}";
if (nodeCounts.TryGetValue(nodeKey, out var nodeCount))
{
nodeCounts[nodeKey] = nodeCount + 1;
}
else
{
nodeCounts[nodeKey] = 1;
}
}
var tenantDecision = TryAcquire(
_tenantBuckets,
tenantCounts,
options.PerTenantEventsPerSecond,
options.PerTenantBurst,
now,
scope: "tenant");
if (!tenantDecision.Allowed)
{
return tenantDecision;
}
var nodeDecision = TryAcquire(
_nodeBuckets,
nodeCounts,
options.PerNodeEventsPerSecond,
options.PerNodeBurst,
now,
scope: "node");
return nodeDecision;
}
private static RateLimitDecision TryAcquire(
ConcurrentDictionary<string, TokenBucket> buckets,
IReadOnlyDictionary<string, int> counts,
double ratePerSecond,
int burst,
DateTimeOffset now,
string scope)
{
if (counts.Count == 0)
{
return RateLimitDecision.Success;
}
var acquired = new List<(TokenBucket bucket, double tokens)>();
foreach (var pair in counts)
{
var bucket = buckets.GetOrAdd(
pair.Key,
_ => new TokenBucket(burst, ratePerSecond, now));
lock (bucket.SyncRoot)
{
bucket.Refill(now);
if (bucket.Tokens + 1e-9 < pair.Value)
{
var deficit = pair.Value - bucket.Tokens;
var retryAfterSeconds = deficit / bucket.RefillRatePerSecond;
var retryAfter = retryAfterSeconds <= 0
? TimeSpan.FromSeconds(1)
: TimeSpan.FromSeconds(Math.Min(retryAfterSeconds, 3600));
// undo previously acquired tokens
foreach (var (acquiredBucket, tokens) in acquired)
{
lock (acquiredBucket.SyncRoot)
{
acquiredBucket.Tokens = Math.Min(acquiredBucket.Capacity, acquiredBucket.Tokens + tokens);
}
}
return new RateLimitDecision(false, scope, pair.Key, retryAfter);
}
bucket.Tokens -= pair.Value;
acquired.Add((bucket, pair.Value));
}
}
return RateLimitDecision.Success;
}
private sealed class TokenBucket
{
public TokenBucket(double capacity, double refillRatePerSecond, DateTimeOffset now)
{
Capacity = capacity;
Tokens = capacity;
RefillRatePerSecond = refillRatePerSecond;
LastRefill = now;
}
public double Capacity { get; }
public double Tokens { get; set; }
public double RefillRatePerSecond { get; }
public DateTimeOffset LastRefill { get; set; }
public object SyncRoot { get; } = new();
public void Refill(DateTimeOffset now)
{
if (now <= LastRefill)
{
return;
}
var elapsedSeconds = (now - LastRefill).TotalSeconds;
if (elapsedSeconds <= 0)
{
return;
}
Tokens = Math.Min(Capacity, Tokens + elapsedSeconds * RefillRatePerSecond);
LastRefill = now;
}
}
}
internal readonly record struct RateLimitDecision(bool Allowed, string? Scope, string? Key, TimeSpan RetryAfter)
{
public static RateLimitDecision Success { get; } = new(true, null, null, TimeSpan.Zero);
}

View File

@@ -0,0 +1,211 @@
using System.Collections.ObjectModel;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Services;
internal interface IRuntimePolicyService
{
Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
}
internal sealed class RuntimePolicyService : IRuntimePolicyService
{
private readonly LinkRepository _linkRepository;
private readonly ArtifactRepository _artifactRepository;
private readonly PolicySnapshotStore _policySnapshotStore;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
public RuntimePolicyService(
LinkRepository linkRepository,
ArtifactRepository artifactRepository,
PolicySnapshotStore policySnapshotStore,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider)
{
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var runtimeOptions = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var ttlSeconds = Math.Max(1, runtimeOptions.PolicyCacheTtlSeconds);
var now = _timeProvider.GetUtcNow();
var expiresAt = now.AddSeconds(ttlSeconds);
var snapshot = await _policySnapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
var policyRevision = snapshot?.RevisionId;
var policyDigest = snapshot?.Digest;
var results = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
foreach (var image in request.Images)
{
var metadata = await ResolveImageMetadataAsync(image, cancellationToken).ConfigureAwait(false);
var decision = BuildDecision(metadata, snapshot, policyDigest);
results[image] = decision;
}
return new RuntimePolicyEvaluationResult(
ttlSeconds,
expiresAt,
policyRevision,
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(results));
}
private async Task<RuntimeImageMetadata> ResolveImageMetadataAsync(string imageDigest, CancellationToken cancellationToken)
{
var links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, imageDigest, cancellationToken).ConfigureAwait(false);
if (links.Count == 0)
{
return new RuntimeImageMetadata(imageDigest, false, false, null, MissingMetadata: true);
}
var hasSbom = false;
var signed = false;
RuntimePolicyRekorReference? rekor = null;
foreach (var link in links)
{
var artifact = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false);
if (artifact is null)
{
continue;
}
switch (artifact.Type)
{
case ArtifactDocumentType.ImageBom:
hasSbom = true;
break;
case ArtifactDocumentType.Attestation:
signed = true;
if (artifact.Rekor is { } rekorReference)
{
rekor = new RuntimePolicyRekorReference(
Normalize(rekorReference.Uuid),
Normalize(rekorReference.Url),
rekorReference.Index.HasValue);
}
break;
}
}
return new RuntimeImageMetadata(imageDigest, signed, hasSbom, rekor, MissingMetadata: false);
}
private static RuntimePolicyImageDecision BuildDecision(RuntimeImageMetadata metadata, PolicySnapshot? snapshot, string? policyDigest)
{
var reasons = new List<string>();
if (metadata.MissingMetadata)
{
reasons.Add("image.metadata.missing");
}
if (!metadata.Signed)
{
reasons.Add("unsigned");
}
if (!metadata.HasSbomReferrers)
{
reasons.Add("missing SBOM");
}
if (snapshot is null)
{
reasons.Add("policy.snapshot.missing");
}
string verdict;
if (snapshot is null)
{
verdict = "unknown";
}
else if (reasons.Count == 0)
{
verdict = "pass";
}
else if (metadata.Signed && metadata.HasSbomReferrers)
{
verdict = "warn";
}
else
{
verdict = "fail";
}
RuntimePolicyRekorReference? rekor = metadata.Rekor;
IDictionary<string, object?>? metadataPayload = null;
if (!string.IsNullOrWhiteSpace(policyDigest) || metadata.MissingMetadata)
{
metadataPayload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["source"] = "scanner.runtime.placeholder"
};
if (!string.IsNullOrWhiteSpace(policyDigest))
{
metadataPayload["policyDigest"] = policyDigest;
}
if (metadata.MissingMetadata)
{
metadataPayload["artifactLinks"] = 0;
}
}
return new RuntimePolicyImageDecision(
verdict,
metadata.Signed,
metadata.HasSbomReferrers,
reasons,
rekor,
metadataPayload);
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value;
}
internal sealed record RuntimePolicyEvaluationRequest(
string? Namespace,
IReadOnlyDictionary<string, string> Labels,
IReadOnlyList<string> Images);
internal sealed record RuntimePolicyEvaluationResult(
int TtlSeconds,
DateTimeOffset ExpiresAtUtc,
string? PolicyRevision,
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Results);
internal sealed record RuntimePolicyImageDecision(
string PolicyVerdict,
bool Signed,
bool HasSbomReferrers,
IReadOnlyList<string> Reasons,
RuntimePolicyRekorReference? Rekor,
IDictionary<string, object?>? Metadata);
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
internal sealed record RuntimeImageMetadata(
string ImageDigest,
bool Signed,
bool HasSbomReferrers,
RuntimePolicyRekorReference? Rekor,
bool MissingMetadata);

View File

@@ -1,7 +1,6 @@
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;
@@ -58,7 +57,8 @@ public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressRe
public int NextSequence() => ++Sequence;
}
private static readonly IReadOnlyDictionary<string, object?> EmptyData = new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
private static readonly IReadOnlyDictionary<string, object?> EmptyData =
new ReadOnlyDictionary<string, object?>(new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
private readonly ConcurrentDictionary<string, ProgressChannel> channels = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
@@ -85,18 +85,14 @@ public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressRe
{
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);
progressEvent = new ScanProgressEvent(
scanId,
sequence,
timeProvider.GetUtcNow(),
state,
message,
correlation,
NormalizePayload(data));
channel.Append(progressEvent);
}
@@ -131,6 +127,24 @@ public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressRe
{
yield return progressEvent;
}
}
}
}
}
}
private static IReadOnlyDictionary<string, object?> NormalizePayload(IReadOnlyDictionary<string, object?>? data)
{
if (data is null || data.Count == 0)
{
return EmptyData;
}
var sorted = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in data)
{
sorted[pair.Key] = pair.Value;
}
return sorted.Count == 0
? EmptyData
: new ReadOnlyDictionary<string, object?>(sorted);
}
}

View File

@@ -12,20 +12,22 @@
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
</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" />
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
</ItemGroup>
</Project>
<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" />
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\StellaOps.Zastava.Core\StellaOps.Zastava.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -10,7 +10,18 @@
| SCANNER-POLICY-09-106 | DONE (2025-10-19) | 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 | DONE (2025-10-19) | 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-WEB-10-201 | DONE (2025-10-19) | Scanner WebService Guild | SCANNER-CACHE-10-101 | Register scanner cache services and maintenance loop within WebService host. | `AddScannerCache` wired for configuration binding; maintenance service skips when disabled; project references updated. |
| 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. |
| SCANNER-RUNTIME-12-302 | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-CORE-12-201 | Implement `/policy/runtime` endpoint joining SBOM baseline + policy verdict, returning admission guidance. Coordinate with CLI (`CLI-RUNTIME-13-008`) before GA to lock response field names/metadata. | Webhook integration test passes; responses include verdict, TTL, reasons; metrics/logging added; CLI contract review signed off. |
| SCANNER-EVENTS-15-201 | DOING (2025-10-19) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Emit `scanner.report.ready` and `scanner.scan.completed` events (bus adapters + tests). | Event envelopes published to queue with schemas; fixtures committed; Notify consumption test passes. |
| SCANNER-RUNTIME-17-401 | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-OBS-17-005, SCANNER-EMIT-17-701, POLICY-RUNTIME-17-201 | Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. | Mongo schema stores optional `buildId`, API/SDK responses document field, integration test resolves debug-store path using stored build-id, docs updated accordingly. |
| SCANNER-RUNTIME-12-301 | DONE (2025-10-20) | 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. |
| SCANNER-RUNTIME-12-302 | DOING (2025-10-20) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-CORE-12-201 | Implement `/policy/runtime` endpoint joining SBOM baseline + policy verdict, returning admission guidance. Coordinate with CLI (`CLI-RUNTIME-13-008`) before GA to lock response field names/metadata. | Webhook integration test passes; responses include verdict, TTL, reasons; metrics/logging added; CLI contract review signed off. |
| SCANNER-RUNTIME-12-303 | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | Replace `/policy/runtime` heuristic with canonical policy evaluation (Feedser/Vexer inputs, PolicyPreviewService) so results align with `/reports`. | Runtime policy endpoint returns canonical verdicts + metadata, tests cover pass/warn/fail cases, docs/CLI updated. |
| SCANNER-RUNTIME-12-304 | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | Surface attestation verification status by integrating Authority/Attestor Rekor validation (beyond presence-only). | Response `rekor.verified` reflects attestor outcome; integration test covers verified/unverified paths; docs updated. |
| SCANNER-RUNTIME-12-305 | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-301, SCANNER-RUNTIME-12-302 | Promote shared fixtures with Zastava/CLI and add end-to-end automation for `/runtime/events` + `/policy/runtime`. | Fixture suite replayed in CI, cross-team sign-off recorded, documentation references test harness. |
| SCANNER-EVENTS-15-201 | DONE (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Emit `scanner.report.ready` and `scanner.scan.completed` events (bus adapters + tests). | Event envelopes published to queue with schemas; fixtures committed; Notify consumption test passes. |
| SCANNER-EVENTS-16-301 | BLOCKED (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Integrate Redis publisher end-to-end once Notify queue abstraction ships; replace in-memory recorder with real stream assertions. | Notify Queue adapter available; integration test exercises Redis stream length/fields via test harness; docs updated with ops validation checklist. |
| SCANNER-RUNTIME-17-401 | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-OBS-17-005, SCANNER-EMIT-17-701, POLICY-RUNTIME-17-201 | Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. | Mongo schema stores optional `buildId`, API/SDK responses document field, integration test resolves debug-store path using stored build-id, docs updated accordingly. |
## Notes
- 2025-10-19: Sprint 9 streaming + policy endpoints (SCANNER-WEB-09-103, SCANNER-POLICY-09-105/106/107) landed with SSE/JSONL, OpenAPI, signed report coverage documented in `docs/09_API_CLI_REFERENCE.md`.
- 2025-10-20: Re-ran `dotnet test src/StellaOps.Scanner.WebService.Tests/StellaOps.Scanner.WebService.Tests.csproj --filter FullyQualifiedName~ReportsEndpointsTests` to confirm DSSE/report regressions stay green after backlog sync.
- 2025-10-20: SCANNER-RUNTIME-12-301 underway `/runtime/events` ingest hitting Mongo with TTL + token-bucket rate limiting; integration tests (`RuntimeEndpointsTests`) green and docs updated with batch contract.
- 2025-10-20: Follow-ups SCANNER-RUNTIME-12-303/304/305 track canonical verdict integration, attestation verification, and cross-guild fixture validation for runtime APIs.
- 2025-10-21: Hardened progress streaming determinism by sorting `data` payload keys within `ScanProgressStream`; added regression `ProgressStreamDataKeysAreSortedDeterministically` ensuring JSONL ordering.