feat: Initialize Zastava Webhook service with TLS and Authority authentication

- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint.
- Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately.
- Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly.
- Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
master
2025-10-19 18:36:22 +03:00
parent 2062da7a8b
commit d099a90f9b
966 changed files with 91038 additions and 1850 deletions

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.WebService.Tests")]

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record PolicyDiagnosticsRequestDto
{
[JsonPropertyName("policy")]
public PolicyPreviewPolicyDto? Policy { get; init; }
}
public sealed record PolicyDiagnosticsResponseDto
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("ruleCount")]
public int RuleCount { get; init; }
[JsonPropertyName("errorCount")]
public int ErrorCount { get; init; }
[JsonPropertyName("warningCount")]
public int WarningCount { get; init; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("issues")]
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
[JsonPropertyName("recommendations")]
public IReadOnlyList<string> Recommendations { get; init; } = Array.Empty<string>();
}

View File

@@ -95,6 +95,22 @@ public sealed record PolicyPreviewVerdictDto
[JsonPropertyName("quiet")]
public bool? Quiet { get; init; }
[JsonPropertyName("unknownConfidence")]
public double? UnknownConfidence { get; init; }
[JsonPropertyName("confidenceBand")]
public string? ConfidenceBand { get; init; }
[JsonPropertyName("unknownAgeDays")]
public double? UnknownAgeDays { get; init; }
[JsonPropertyName("sourceTrust")]
public string? SourceTrust { get; init; }
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
public sealed record PolicyPreviewPolicyDto

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ReportRequestDto
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("findings")]
public IReadOnlyList<PolicyPreviewFindingDto>? Findings { get; init; }
[JsonPropertyName("baseline")]
public IReadOnlyList<PolicyPreviewVerdictDto>? Baseline { get; init; }
}
public sealed record ReportResponseDto
{
[JsonPropertyName("report")]
public ReportDocumentDto Report { get; init; } = new();
[JsonPropertyName("dsse")]
public DsseEnvelopeDto? Dsse { get; init; }
}
public sealed record ReportDocumentDto
{
[JsonPropertyName("reportId")]
[JsonPropertyOrder(0)]
public string ReportId { get; init; } = string.Empty;
[JsonPropertyName("imageDigest")]
[JsonPropertyOrder(1)]
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
[JsonPropertyOrder(2)]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("verdict")]
[JsonPropertyOrder(3)]
public string Verdict { get; init; } = string.Empty;
[JsonPropertyName("policy")]
[JsonPropertyOrder(4)]
public ReportPolicyDto Policy { get; init; } = new();
[JsonPropertyName("summary")]
[JsonPropertyOrder(5)]
public ReportSummaryDto Summary { get; init; } = new();
[JsonPropertyName("verdicts")]
[JsonPropertyOrder(6)]
public IReadOnlyList<PolicyPreviewVerdictDto> Verdicts { get; init; } = Array.Empty<PolicyPreviewVerdictDto>();
[JsonPropertyName("issues")]
[JsonPropertyOrder(7)]
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
}
public sealed record ReportPolicyDto
{
[JsonPropertyName("revisionId")]
[JsonPropertyOrder(0)]
public string? RevisionId { get; init; }
[JsonPropertyName("digest")]
[JsonPropertyOrder(1)]
public string? Digest { get; init; }
}
public sealed record ReportSummaryDto
{
[JsonPropertyName("total")]
[JsonPropertyOrder(0)]
public int Total { get; init; }
[JsonPropertyName("blocked")]
[JsonPropertyOrder(1)]
public int Blocked { get; init; }
[JsonPropertyName("warned")]
[JsonPropertyOrder(2)]
public int Warned { get; init; }
[JsonPropertyName("ignored")]
[JsonPropertyOrder(3)]
public int Ignored { get; init; }
[JsonPropertyName("quieted")]
[JsonPropertyOrder(4)]
public int Quieted { get; init; }
}
public sealed record DsseEnvelopeDto
{
[JsonPropertyName("payloadType")]
[JsonPropertyOrder(0)]
public string PayloadType { get; init; } = string.Empty;
[JsonPropertyName("payload")]
[JsonPropertyOrder(1)]
public string Payload { get; init; } = string.Empty;
[JsonPropertyName("signatures")]
[JsonPropertyOrder(2)]
public IReadOnlyList<DsseSignatureDto> Signatures { get; init; } = Array.Empty<DsseSignatureDto>();
}
public sealed record DsseSignatureDto
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = string.Empty;
[JsonPropertyName("signature")]
public string Signature { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,175 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class PolicyEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapPolicyEndpoints(this RouteGroupBuilder apiGroup, string policySegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var policyGroup = apiGroup
.MapGroup(NormalizeSegment(policySegment))
.WithTags("Policy");
policyGroup.MapGet("/schema", HandleSchemaAsync)
.WithName("scanner.policy.schema")
.Produces(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Retrieve the embedded policy JSON schema.";
operation.Description = "Returns the policy schema (`policy-schema@1`) used to validate YAML or JSON rulesets.";
return operation;
});
policyGroup.MapPost("/diagnostics", HandleDiagnosticsAsync)
.WithName("scanner.policy.diagnostics")
.Produces<PolicyDiagnosticsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Run policy diagnostics.";
operation.Description = "Accepts YAML or JSON policy content and returns normalization issues plus recommendations (ignore rules, VEX include/exclude, vendor precedence).";
return operation;
});
policyGroup.MapPost("/preview", HandlePreviewAsync)
.WithName("scanner.policy.preview")
.Produces<PolicyPreviewResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Preview policy impact against findings.";
operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages.";
return operation;
});
}
private static IResult HandleSchemaAsync(HttpContext context)
{
var schema = PolicySchemaResource.ReadSchemaJson();
return Results.Text(schema, "application/schema+json", Encoding.UTF8);
}
private static IResult HandleDiagnosticsAsync(
PolicyDiagnosticsRequestDto request,
TimeProvider timeProvider,
HttpContext context)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(timeProvider);
if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Content))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy diagnostics request",
StatusCodes.Status400BadRequest,
detail: "Policy content is required for diagnostics.");
}
var format = PolicyDtoMapper.ParsePolicyFormat(request.Policy.Format);
var binding = PolicyBinder.Bind(request.Policy.Content, format);
var diagnostics = PolicyDiagnostics.Create(binding, timeProvider);
var response = new PolicyDiagnosticsResponseDto
{
Success = diagnostics.ErrorCount == 0,
Version = diagnostics.Version,
RuleCount = diagnostics.RuleCount,
ErrorCount = diagnostics.ErrorCount,
WarningCount = diagnostics.WarningCount,
GeneratedAt = diagnostics.GeneratedAt,
Issues = diagnostics.Issues.Select(PolicyDtoMapper.ToIssueDto).ToImmutableArray(),
Recommendations = diagnostics.Recommendations
};
return Json(response);
}
private static async Task<IResult> HandlePreviewAsync(
PolicyPreviewRequestDto request,
PolicyPreviewService previewService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(previewService);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null)
{
var missingIds = request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id));
if (missingIds)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
}
var domainRequest = PolicyDtoMapper.ToDomain(request);
var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var payload = PolicyDtoMapper.ToDto(response);
return Json(payload);
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/policy";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
}

View File

@@ -0,0 +1,266 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class ReportEndpoints
{
private const string PayloadType = "application/vnd.stellaops.report+json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static void MapReportEndpoints(this RouteGroupBuilder apiGroup, string reportsSegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var reports = apiGroup
.MapGroup(NormalizeSegment(reportsSegment))
.WithTags("Reports");
reports.MapPost("/", HandleCreateReportAsync)
.WithName("scanner.reports.create")
.Produces<ReportResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status503ServiceUnavailable)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Assemble a signed scan report.";
operation.Description = "Aggregates latest findings with the active policy snapshot, returning verdicts plus an optional DSSE envelope.";
return operation;
});
}
private static async Task<IResult> HandleCreateReportAsync(
ReportRequestDto request,
PolicyPreviewService previewService,
IReportSigner signer,
TimeProvider timeProvider,
IReportEventDispatcher eventDispatcher,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(previewService);
ArgumentNullException.ThrowIfNull(signer);
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentNullException.ThrowIfNull(eventDispatcher);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null && request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id)))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
var previewDto = new PolicyPreviewRequestDto
{
ImageDigest = request.ImageDigest,
Findings = request.Findings,
Baseline = request.Baseline,
Policy = null
};
var domainRequest = PolicyDtoMapper.ToDomain(previewDto) with { ProposedPolicy = null };
var preview = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
if (!preview.Success)
{
var issues = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["issues"] = issues
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Unable to assemble report",
StatusCodes.Status503ServiceUnavailable,
detail: "No policy snapshot is available or validation failed.",
extensions: extensions);
}
var projectedVerdicts = preview.Diffs
.Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected))
.ToArray();
var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
var summary = BuildSummary(projectedVerdicts);
var verdict = ComputeVerdict(projectedVerdicts);
var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest);
var generatedAt = timeProvider.GetUtcNow();
var document = new ReportDocumentDto
{
ReportId = reportId,
ImageDigest = request.ImageDigest!,
GeneratedAt = generatedAt,
Verdict = verdict,
Policy = new ReportPolicyDto
{
RevisionId = preview.RevisionId,
Digest = preview.PolicyDigest
},
Summary = summary,
Verdicts = projectedVerdicts,
Issues = issuesDto
};
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
var signature = signer.Sign(payloadBytes);
DsseEnvelopeDto? envelope = null;
if (signature is not null)
{
envelope = new DsseEnvelopeDto
{
PayloadType = PayloadType,
Payload = Convert.ToBase64String(payloadBytes),
Signatures = new[]
{
new DsseSignatureDto
{
KeyId = signature.KeyId,
Algorithm = signature.Algorithm,
Signature = signature.Signature
}
}
};
}
var response = new ReportResponseDto
{
Report = document,
Dsse = envelope
};
await eventDispatcher
.PublishAsync(request, preview, document, envelope, context, cancellationToken)
.ConfigureAwait(false);
return Json(response);
}
private static ReportSummaryDto BuildSummary(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
{
if (verdicts.Count == 0)
{
return new ReportSummaryDto { Total = 0 };
}
var blocked = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase));
var warned = verdicts.Count(v =>
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase));
var ignored = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Ignored), StringComparison.OrdinalIgnoreCase));
var quieted = verdicts.Count(v => v.Quiet is true);
return new ReportSummaryDto
{
Total = verdicts.Count,
Blocked = blocked,
Warned = warned,
Ignored = ignored,
Quieted = quieted
};
}
private static string ComputeVerdict(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
{
if (verdicts.Count == 0)
{
return "unknown";
}
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase)))
{
return "blocked";
}
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase)))
{
return "escalated";
}
if (verdicts.Any(v =>
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)))
{
return "warn";
}
return "pass";
}
private static string CreateReportId(string imageDigest, string policyDigest)
{
var builder = new StringBuilder();
builder.Append(imageDigest.Trim());
builder.Append('|');
builder.Append(policyDigest ?? string.Empty);
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
var hex = Convert.ToHexString(hash.AsSpan(0, 10)).ToLowerInvariant();
return $"report-{hex}";
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/reports";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
}

View File

@@ -23,11 +23,11 @@ internal static class ScanEndpoints
Converters = { new JsonStringEnumConverter() }
};
public static void MapScanEndpoints(this RouteGroupBuilder apiGroup)
public static void MapScanEndpoints(this RouteGroupBuilder apiGroup, string scansSegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var scans = apiGroup.MapGroup("/scans");
var scans = apiGroup.MapGroup(NormalizeSegment(scansSegment));
scans.MapPost("/", HandleSubmitAsync)
.WithName("scanner.scans.submit")
@@ -295,4 +295,15 @@ internal static class ScanEndpoints
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/scans";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
}

View File

@@ -0,0 +1,58 @@
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Scanner.WebService.Extensions;
internal static class OpenApiRegistrationExtensions
{
public static IServiceCollection AddOpenApiIfAvailable(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
var extensionType = Type.GetType("Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions, Microsoft.AspNetCore.OpenApi");
if (extensionType is not null)
{
var method = extensionType
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.FirstOrDefault(m =>
string.Equals(m.Name, "AddOpenApi", StringComparison.Ordinal) &&
m.GetParameters().Length == 2);
if (method is not null)
{
var result = method.Invoke(null, new object?[] { services, null });
if (result is IServiceCollection collection)
{
return collection;
}
}
}
services.AddEndpointsApiExplorer();
return services;
}
public static WebApplication MapOpenApiIfAvailable(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
var extensionType = Type.GetType("Microsoft.AspNetCore.Builder.OpenApiApplicationBuilderExtensions, Microsoft.AspNetCore.OpenApi");
if (extensionType is not null)
{
var method = extensionType
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.FirstOrDefault(m =>
string.Equals(m.Name, "MapOpenApi", StringComparison.Ordinal) &&
m.GetParameters().Length == 1);
if (method is not null)
{
method.Invoke(null, new object?[] { app });
}
}
return app;
}
}

View File

@@ -60,6 +60,11 @@ public sealed class ScannerWebServiceOptions
/// </summary>
public ApiOptions Api { get; set; } = new();
/// <summary>
/// Platform event emission settings.
/// </summary>
public EventsOptions Events { get; set; } = new();
public sealed class StorageOptions
{
public string Driver { get; set; } = "mongo";
@@ -214,6 +219,8 @@ public sealed class ScannerWebServiceOptions
public string Algorithm { get; set; } = "ed25519";
public string? Provider { get; set; }
public string? KeyPem { get; set; }
public string? KeyPemFile { get; set; }
@@ -236,5 +243,24 @@ public sealed class ScannerWebServiceOptions
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 string Driver { get; set; } = "redis";
public string Dsn { get; set; } = string.Empty;
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);
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Scanner.WebService.Options;
@@ -54,6 +55,28 @@ public static class ScannerWebServiceOptionsPostConfigure
{
signing.CertificateChainPem = ReadAllText(signing.CertificateChainPemFile!, contentRootPath);
}
options.Events ??= new ScannerWebServiceOptions.EventsOptions();
var eventsOptions = options.Events;
eventsOptions.DriverSettings ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(eventsOptions.Driver))
{
eventsOptions.Driver = "redis";
}
if (string.IsNullOrWhiteSpace(eventsOptions.Stream))
{
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;
}
}
private static string ReadSecretFile(string path, string contentRootPath)

View File

@@ -28,6 +28,11 @@ public static class ScannerWebServiceOptionsValidator
"minio"
};
private static readonly HashSet<string> SupportedEventDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"redis"
};
public static void Validate(ScannerWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
@@ -62,6 +67,24 @@ public static class ScannerWebServiceOptionsValidator
{
throw new InvalidOperationException("API basePath must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.ScansSegment))
{
throw new InvalidOperationException("API scansSegment must be configured.");
}
if (string.IsNullOrWhiteSpace(options.Api.ReportsSegment))
{
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);
}
private static void ValidateStorage(ScannerWebServiceOptions.StorageOptions storage)
@@ -143,6 +166,39 @@ public static class ScannerWebServiceOptionsValidator
}
}
private static void ValidateEvents(ScannerWebServiceOptions.EventsOptions eventsOptions)
{
if (!eventsOptions.Enabled)
{
return;
}
if (!SupportedEventDrivers.Contains(eventsOptions.Driver))
{
throw new InvalidOperationException($"Unsupported events driver '{eventsOptions.Driver}'. Supported drivers: redis.");
}
if (string.IsNullOrWhiteSpace(eventsOptions.Dsn))
{
throw new InvalidOperationException("Events DSN must be configured when event emission is enabled.");
}
if (string.IsNullOrWhiteSpace(eventsOptions.Stream))
{
throw new InvalidOperationException("Events stream must be configured when event emission is enabled.");
}
if (eventsOptions.PublishTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Events publishTimeoutSeconds must be greater than zero.");
}
if (eventsOptions.MaxStreamLength < 0)
{
throw new InvalidOperationException("Events maxStreamLength must be zero or greater.");
}
}
private static void ValidateTelemetry(ScannerWebServiceOptions.TelemetryOptions telemetry)
{
if (string.IsNullOrWhiteSpace(telemetry.MinimumLogLevel))

View File

@@ -15,6 +15,10 @@ 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;
@@ -64,17 +68,35 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
});
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>();
builder.Services.AddSingleton<IPolicySnapshotRepository, InMemoryPolicySnapshotRepository>();
builder.Services.AddSingleton<IPolicyAuditRepository, InMemoryPolicyAuditRepository>();
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>();
var pluginHostOptions = ScannerPluginHostFactory.Build(bootstrapOptions, contentRoot);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApiIfAvailable();
if (bootstrapOptions.Authority.Enabled)
{
@@ -241,5 +263,14 @@ if (app.Environment.IsEnvironment("Testing"))
.WithName("scanner.auth-probe");
}
apiGroup.MapScanEndpoints();
apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
if (resolvedOptions.Features.EnablePolicyPreview)
{
apiGroup.MapPolicyEndpoints(resolvedOptions.Api.PolicySegment);
}
apiGroup.MapReportEndpoints(resolvedOptions.Api.ReportsSegment);
app.MapOpenApiIfAvailable();
await app.RunAsync().ConfigureAwait(false);

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Publishes platform events to the internal bus consumed by downstream services (Notify, UI, etc.).
/// </summary>
public interface IPlatformEventPublisher
{
/// <summary>
/// Publishes the supplied event envelope.
/// </summary>
Task PublishAsync(NotifyEvent @event, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Coordinates generation and publication of scanner-related platform events.
/// </summary>
public interface IReportEventDispatcher
{
Task PublishAsync(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
HttpContext httpContext,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// No-op fallback publisher used until queue adapters register a concrete implementation.
/// </summary>
internal sealed class NullPlatformEventPublisher : IPlatformEventPublisher
{
private readonly ILogger<NullPlatformEventPublisher> _logger;
public NullPlatformEventPublisher(ILogger<NullPlatformEventPublisher> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task PublishAsync(NotifyEvent @event, CancellationToken cancellationToken = default)
{
if (@event is null)
{
throw new ArgumentNullException(nameof(@event));
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Suppressing publish for event {EventKind} (tenant {Tenant}).", @event.Kind, @event.Tenant);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,356 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal static class PolicyDtoMapper
{
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
public static PolicyPreviewRequest ToDomain(PolicyPreviewRequestDto request)
{
ArgumentNullException.ThrowIfNull(request);
var findings = BuildFindings(request.Findings);
var baseline = BuildBaseline(request.Baseline);
var proposedPolicy = ToSnapshotContent(request.Policy);
return new PolicyPreviewRequest(
request.ImageDigest!.Trim(),
findings,
baseline,
SnapshotOverride: null,
ProposedPolicy: proposedPolicy);
}
public static PolicyPreviewResponseDto ToDto(PolicyPreviewResponse response)
{
ArgumentNullException.ThrowIfNull(response);
var diffs = response.Diffs.Select(ToDiffDto).ToImmutableArray();
var issues = response.Issues.Select(ToIssueDto).ToImmutableArray();
return new PolicyPreviewResponseDto
{
Success = response.Success,
PolicyDigest = response.PolicyDigest,
RevisionId = response.RevisionId,
Changed = response.ChangedCount,
Diffs = diffs,
Issues = issues
};
}
public static PolicyPreviewIssueDto ToIssueDto(PolicyIssue issue)
{
ArgumentNullException.ThrowIfNull(issue);
return new PolicyPreviewIssueDto
{
Code = issue.Code,
Message = issue.Message,
Severity = issue.Severity.ToString(),
Path = issue.Path
};
}
public static PolicyDocumentFormat ParsePolicyFormat(string? format)
=> string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)
? PolicyDocumentFormat.Json
: PolicyDocumentFormat.Yaml;
private static ImmutableArray<PolicyFinding> BuildFindings(IReadOnlyList<PolicyPreviewFindingDto>? findings)
{
if (findings is null || findings.Count == 0)
{
return ImmutableArray<PolicyFinding>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyFinding>(findings.Count);
foreach (var finding in findings)
{
if (finding is null)
{
continue;
}
var tags = finding.Tags is { Count: > 0 }
? finding.Tags.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim())
.ToImmutableArray()
: ImmutableArray<string>.Empty;
var severity = ParseSeverity(finding.Severity);
var candidate = PolicyFinding.Create(
finding.Id!.Trim(),
severity,
environment: Normalize(finding.Environment),
source: Normalize(finding.Source),
vendor: Normalize(finding.Vendor),
license: Normalize(finding.License),
image: Normalize(finding.Image),
repository: Normalize(finding.Repository),
package: Normalize(finding.Package),
purl: Normalize(finding.Purl),
cve: Normalize(finding.Cve),
path: Normalize(finding.Path),
layerDigest: Normalize(finding.LayerDigest),
tags: tags);
builder.Add(candidate);
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyVerdict> BuildBaseline(IReadOnlyList<PolicyPreviewVerdictDto>? baseline)
{
if (baseline is null || baseline.Count == 0)
{
return ImmutableArray<PolicyVerdict>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyVerdict>(baseline.Count);
foreach (var verdict in baseline)
{
if (verdict is null || string.IsNullOrWhiteSpace(verdict.FindingId))
{
continue;
}
var inputs = verdict.Inputs is { Count: > 0 }
? CreateImmutableDeterministicDictionary(verdict.Inputs)
: ImmutableDictionary<string, double>.Empty;
var status = ParseVerdictStatus(verdict.Status);
builder.Add(new PolicyVerdict(
verdict.FindingId!.Trim(),
status,
verdict.RuleName,
verdict.RuleAction,
verdict.Notes,
verdict.Score ?? 0,
verdict.ConfigVersion ?? PolicyScoringConfig.Default.Version,
inputs,
verdict.QuietedBy,
verdict.Quiet ?? false,
verdict.UnknownConfidence,
verdict.ConfidenceBand,
verdict.UnknownAgeDays,
verdict.SourceTrust,
verdict.Reachability));
}
return builder.ToImmutable();
}
private static PolicyPreviewDiffDto ToDiffDto(PolicyVerdictDiff diff)
{
ArgumentNullException.ThrowIfNull(diff);
return new PolicyPreviewDiffDto
{
FindingId = diff.Projected.FindingId,
Baseline = ToVerdictDto(diff.Baseline),
Projected = ToVerdictDto(diff.Projected),
Changed = diff.Changed
};
}
internal static PolicyPreviewVerdictDto ToVerdictDto(PolicyVerdict verdict)
{
ArgumentNullException.ThrowIfNull(verdict);
IReadOnlyDictionary<string, double>? inputs = null;
var verdictInputs = verdict.GetInputs();
if (verdictInputs.Count > 0)
{
inputs = CreateDeterministicInputs(verdictInputs);
}
var sourceTrust = verdict.SourceTrust;
if (string.IsNullOrWhiteSpace(sourceTrust))
{
sourceTrust = ExtractSuffix(verdictInputs, "trustWeight.");
}
var reachability = verdict.Reachability;
if (string.IsNullOrWhiteSpace(reachability))
{
reachability = ExtractSuffix(verdictInputs, "reachability.");
}
return new PolicyPreviewVerdictDto
{
FindingId = verdict.FindingId,
Status = verdict.Status.ToString(),
RuleName = verdict.RuleName,
RuleAction = verdict.RuleAction,
Notes = verdict.Notes,
Score = verdict.Score,
ConfigVersion = verdict.ConfigVersion,
Inputs = inputs,
QuietedBy = verdict.QuietedBy,
Quiet = verdict.Quiet,
UnknownConfidence = verdict.UnknownConfidence,
ConfidenceBand = verdict.ConfidenceBand,
UnknownAgeDays = verdict.UnknownAgeDays,
SourceTrust = sourceTrust,
Reachability = reachability
};
}
private static ImmutableDictionary<string, double> CreateImmutableDeterministicDictionary(IEnumerable<KeyValuePair<string, double>> inputs)
{
var sorted = CreateDeterministicInputs(inputs);
var builder = ImmutableDictionary.CreateBuilder<string, double>(OrdinalIgnoreCase);
foreach (var pair in sorted)
{
builder[pair.Key] = pair.Value;
}
return builder.ToImmutable();
}
private static IReadOnlyDictionary<string, double> CreateDeterministicInputs(IEnumerable<KeyValuePair<string, double>> inputs)
{
ArgumentNullException.ThrowIfNull(inputs);
var dictionary = new SortedDictionary<string, double>(InputKeyComparer.Instance);
foreach (var pair in inputs)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
var key = pair.Key.Trim();
dictionary[key] = pair.Value;
}
return dictionary;
}
private sealed class InputKeyComparer : IComparer<string>
{
public static InputKeyComparer Instance { get; } = new();
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y))
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
var px = GetPriority(x);
var py = GetPriority(y);
if (px != py)
{
return px.CompareTo(py);
}
return string.Compare(x, y, StringComparison.Ordinal);
}
private static int GetPriority(string key)
{
if (string.Equals(key, "reachabilityWeight", StringComparison.OrdinalIgnoreCase))
{
return 0;
}
if (string.Equals(key, "baseScore", StringComparison.OrdinalIgnoreCase))
{
return 1;
}
if (string.Equals(key, "severityWeight", StringComparison.OrdinalIgnoreCase))
{
return 2;
}
if (string.Equals(key, "trustWeight", StringComparison.OrdinalIgnoreCase))
{
return 3;
}
if (key.StartsWith("trustWeight.", StringComparison.OrdinalIgnoreCase))
{
return 4;
}
if (key.StartsWith("reachability.", StringComparison.OrdinalIgnoreCase))
{
return 5;
}
return 6;
}
}
private static PolicySnapshotContent? ToSnapshotContent(PolicyPreviewPolicyDto? policy)
{
if (policy is null || string.IsNullOrWhiteSpace(policy.Content))
{
return null;
}
var format = ParsePolicyFormat(policy.Format);
return new PolicySnapshotContent(
policy.Content,
format,
policy.Actor,
Source: null,
policy.Description);
}
private static PolicySeverity ParseSeverity(string? value)
{
if (Enum.TryParse<PolicySeverity>(value, true, out var severity))
{
return severity;
}
return PolicySeverity.Unknown;
}
private static PolicyVerdictStatus ParseVerdictStatus(string? value)
{
if (Enum.TryParse<PolicyVerdictStatus>(value, true, out var status))
{
return status;
}
return PolicyVerdictStatus.Pass;
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? ExtractSuffix(ImmutableDictionary<string, double> inputs, string prefix)
{
foreach (var key in inputs.Keys)
{
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && key.Length > prefix.Length)
{
return key.Substring(prefix.Length);
}
}
return null;
}
}

View File

@@ -0,0 +1,148 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAsyncDisposable
{
private readonly ScannerWebServiceOptions.EventsOptions _options;
private readonly ILogger<RedisPlatformEventPublisher> _logger;
private readonly TimeSpan _publishTimeout;
private readonly string _streamKey;
private readonly long? _maxStreamLength;
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private IConnectionMultiplexer? _connection;
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.");
if (!_options.Enabled)
{
throw new InvalidOperationException("RedisPlatformEventPublisher requires events emission to be enabled.");
}
if (!string.Equals(_options.Driver, "redis", StringComparison.OrdinalIgnoreCase))
{
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;
}
public async Task PublishAsync(NotifyEvent @event, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(@event);
cancellationToken.ThrowIfCancellationRequested();
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var payload = NotifyCanonicalJsonSerializer.Serialize(@event);
var entries = new NameValueEntry[]
{
new("event", payload),
new("kind", @event.Kind),
new("tenant", @event.Tenant),
new("ts", @event.Ts.ToString("O"))
};
int? maxLength = null;
if (_maxStreamLength.HasValue)
{
var clamped = Math.Min(_maxStreamLength.Value, int.MaxValue);
maxLength = (int)clamped;
}
var publishTask = maxLength.HasValue
? database.StreamAddAsync(_streamKey, entries, maxLength: maxLength, useApproximateMaxLength: true)
: database.StreamAddAsync(_streamKey, entries);
if (_publishTimeout > TimeSpan.Zero)
{
await publishTask.WaitAsync(_publishTimeout, cancellationToken).ConfigureAwait(false);
}
else
{
await publishTask.ConfigureAwait(false);
}
}
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_connection is not null && _connection.IsConnected)
{
return _connection.GetDatabase();
}
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is null || !_connection.IsConnected)
{
var config = ConfigurationOptions.Parse(_options.Dsn);
config.AbortOnConnectFail = false;
if (_options.DriverSettings.TryGetValue("clientName", out var clientName) && !string.IsNullOrWhiteSpace(clientName))
{
config.ClientName = clientName;
}
if (_options.DriverSettings.TryGetValue("ssl", out var sslValue) && bool.TryParse(sslValue, out var ssl))
{
config.Ssl = ssl;
}
_connection = await ConnectionMultiplexer.ConnectAsync(config).WaitAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Connected Redis platform event publisher to stream {Stream}.", _streamKey);
}
}
finally
{
_connectionGate.Release();
}
return _connection!.GetDatabase();
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
try
{
await _connection.CloseAsync();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error while closing Redis platform event publisher connection.");
}
_connection.Dispose();
}
_connectionGate.Dispose();
}
}

View File

@@ -0,0 +1,520 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Notify.Models;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class ReportEventDispatcher : IReportEventDispatcher
{
private const string DefaultTenant = "default";
private const string Actor = "scanner.webservice";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly IPlatformEventPublisher _publisher;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ReportEventDispatcher> _logger;
public ReportEventDispatcher(
IPlatformEventPublisher publisher,
TimeProvider timeProvider,
ILogger<ReportEventDispatcher> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task PublishAsync(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
HttpContext httpContext,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(preview);
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(httpContext);
cancellationToken.ThrowIfCancellationRequested();
var now = _timeProvider.GetUtcNow();
var tenant = ResolveTenant(httpContext);
var scope = BuildScope(request, document);
var attributes = BuildAttributes(document);
var reportPayload = BuildReportReadyPayload(request, preview, document, envelope, httpContext);
var reportEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
tenant: tenant,
ts: document.GeneratedAt == default ? now : document.GeneratedAt,
payload: reportPayload,
scope: scope,
actor: Actor,
attributes: attributes);
await PublishSafelyAsync(reportEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
var scanPayload = BuildScanCompletedPayload(request, preview, document, envelope);
var scanEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerScanCompleted,
tenant: tenant,
ts: document.GeneratedAt == default ? now : document.GeneratedAt,
payload: scanPayload,
scope: scope,
actor: Actor,
attributes: attributes);
await PublishSafelyAsync(scanEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
}
private async Task PublishSafelyAsync(NotifyEvent @event, string reportId, CancellationToken cancellationToken)
{
try
{
await _publisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to publish event {EventKind} for report {ReportId}.",
@event.Kind,
reportId);
}
}
private static string ResolveTenant(HttpContext context)
{
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenant))
{
return tenant.Trim();
}
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
{
var headerValue = headerTenant.ToString();
if (!string.IsNullOrWhiteSpace(headerValue))
{
return headerValue.Trim();
}
}
return DefaultTenant;
}
private static NotifyEventScope BuildScope(ReportRequestDto request, ReportDocumentDto document)
{
var repository = ResolveRepository(request);
var (ns, repo) = SplitRepository(repository);
var digest = string.IsNullOrWhiteSpace(document.ImageDigest)
? request.ImageDigest ?? string.Empty
: document.ImageDigest;
return NotifyEventScope.Create(
@namespace: ns,
repo: string.IsNullOrWhiteSpace(repo) ? "(unknown)" : repo,
digest: string.IsNullOrWhiteSpace(digest) ? "(unknown)" : digest);
}
private static string ResolveRepository(ReportRequestDto request)
{
if (request.Findings is { Count: > 0 })
{
foreach (var finding in request.Findings)
{
if (!string.IsNullOrWhiteSpace(finding.Repository))
{
return finding.Repository!.Trim();
}
if (!string.IsNullOrWhiteSpace(finding.Image))
{
return finding.Image!.Trim();
}
}
}
return string.Empty;
}
private static (string? Namespace, string Repo) SplitRepository(string repository)
{
if (string.IsNullOrWhiteSpace(repository))
{
return (null, string.Empty);
}
var normalized = repository.Trim();
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
return (null, normalized);
}
if (segments.Length == 1)
{
return (null, segments[0]);
}
var repo = segments[^1];
var ns = string.Join('/', segments[..^1]);
return (ns, repo);
}
private static IEnumerable<KeyValuePair<string, string>> BuildAttributes(ReportDocumentDto document)
{
var attributes = new List<KeyValuePair<string, string>>(capacity: 4)
{
new("reportId", document.ReportId)
};
if (!string.IsNullOrWhiteSpace(document.Policy.RevisionId))
{
attributes.Add(new("policyRevisionId", document.Policy.RevisionId!));
}
if (!string.IsNullOrWhiteSpace(document.Policy.Digest))
{
attributes.Add(new("policyDigest", document.Policy.Digest!));
}
attributes.Add(new("verdict", document.Verdict));
return attributes;
}
private static JsonObject BuildReportReadyPayload(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
HttpContext context)
{
var payload = new JsonObject
{
["reportId"] = document.ReportId,
["generatedAt"] = document.GeneratedAt == default
? null
: JsonValue.Create(document.GeneratedAt),
["verdict"] = MapVerdict(document.Verdict),
["summary"] = JsonSerializer.SerializeToNode(document.Summary, JsonOptions),
["delta"] = BuildDelta(preview, request),
["links"] = BuildLinks(context, document),
["quietedFindingCount"] = document.Summary.Quieted
};
payload.RemoveNulls();
if (envelope is not null)
{
payload["dsse"] = JsonSerializer.SerializeToNode(envelope, JsonOptions);
}
payload["report"] = JsonSerializer.SerializeToNode(document, JsonOptions);
return payload;
}
private static JsonObject BuildScanCompletedPayload(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope)
{
var payload = new JsonObject
{
["reportId"] = document.ReportId,
["digest"] = document.ImageDigest,
["summary"] = JsonSerializer.SerializeToNode(document.Summary, JsonOptions),
["verdict"] = MapVerdict(document.Verdict),
["policy"] = JsonSerializer.SerializeToNode(document.Policy, JsonOptions),
["delta"] = BuildDelta(preview, request),
["report"] = JsonSerializer.SerializeToNode(document, JsonOptions)
};
if (envelope is not null)
{
payload["dsse"] = JsonSerializer.SerializeToNode(envelope, JsonOptions);
}
payload["findings"] = BuildFindingSummaries(request);
payload.RemoveNulls();
return payload;
}
private static JsonArray BuildFindingSummaries(ReportRequestDto request)
{
var array = new JsonArray();
if (request.Findings is { Count: > 0 })
{
foreach (var finding in request.Findings)
{
if (string.IsNullOrWhiteSpace(finding.Id))
{
continue;
}
var summary = new JsonObject
{
["id"] = finding.Id,
["severity"] = finding.Severity,
["cve"] = finding.Cve,
["purl"] = finding.Purl,
["reachability"] = ResolveReachability(finding.Tags)
};
summary.RemoveNulls();
array.Add(summary);
}
}
return array;
}
private static string? ResolveReachability(IReadOnlyList<string>? tags)
{
if (tags is null)
{
return null;
}
foreach (var tag in tags)
{
if (string.IsNullOrWhiteSpace(tag))
{
continue;
}
if (tag.StartsWith("reachability:", StringComparison.OrdinalIgnoreCase))
{
return tag["reachability:".Length..];
}
}
return null;
}
private static JsonObject BuildDelta(PolicyPreviewResponse preview, ReportRequestDto request)
{
var delta = new JsonObject();
if (preview.Diffs.IsDefaultOrEmpty)
{
return delta;
}
var findings = BuildFindingsIndex(request.Findings);
var kevIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var newCritical = 0;
var newHigh = 0;
foreach (var diff in preview.Diffs)
{
var projected = diff.Projected;
if (projected is null || string.IsNullOrWhiteSpace(projected.FindingId))
{
continue;
}
if (!findings.TryGetValue(projected.FindingId, out var finding))
{
finding = null;
}
if (IsNewlyImportant(diff))
{
var severity = finding?.Severity;
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase))
{
newCritical++;
}
else if (string.Equals(severity, "High", StringComparison.OrdinalIgnoreCase))
{
newHigh++;
}
var kevId = ResolveKevIdentifier(finding);
if (!string.IsNullOrWhiteSpace(kevId))
{
kevIds.Add(kevId);
}
}
}
if (newCritical > 0)
{
delta["newCritical"] = newCritical;
}
if (newHigh > 0)
{
delta["newHigh"] = newHigh;
}
if (kevIds.Count > 0)
{
var kev = new JsonArray();
foreach (var id in kevIds)
{
kev.Add(id);
}
delta["kev"] = kev;
}
return delta;
}
private static ImmutableDictionary<string, PolicyPreviewFindingDto> BuildFindingsIndex(
IReadOnlyList<PolicyPreviewFindingDto>? findings)
{
if (findings is null || findings.Count == 0)
{
return ImmutableDictionary<string, PolicyPreviewFindingDto>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, PolicyPreviewFindingDto>(StringComparer.Ordinal);
foreach (var finding in findings)
{
if (string.IsNullOrWhiteSpace(finding.Id))
{
continue;
}
if (!builder.ContainsKey(finding.Id))
{
builder.Add(finding.Id, finding);
}
}
return builder.ToImmutable();
}
private static bool IsNewlyImportant(PolicyVerdictDiff diff)
{
var projected = diff.Projected.Status;
var baseline = diff.Baseline.Status;
return projected switch
{
PolicyVerdictStatus.Blocked or PolicyVerdictStatus.Escalated
=> baseline != PolicyVerdictStatus.Blocked && baseline != PolicyVerdictStatus.Escalated,
PolicyVerdictStatus.Warned or PolicyVerdictStatus.Deferred or PolicyVerdictStatus.RequiresVex
=> baseline != PolicyVerdictStatus.Warned
&& baseline != PolicyVerdictStatus.Deferred
&& baseline != PolicyVerdictStatus.RequiresVex
&& baseline != PolicyVerdictStatus.Blocked
&& baseline != PolicyVerdictStatus.Escalated,
_ => false
};
}
private static string? ResolveKevIdentifier(PolicyPreviewFindingDto? finding)
{
if (finding is null)
{
return null;
}
var tags = finding.Tags;
if (tags is not null)
{
foreach (var tag in tags)
{
if (string.IsNullOrWhiteSpace(tag))
{
continue;
}
if (string.Equals(tag, "kev", StringComparison.OrdinalIgnoreCase))
{
return finding.Cve;
}
if (tag.StartsWith("kev:", StringComparison.OrdinalIgnoreCase))
{
var value = tag["kev:".Length..];
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
}
}
return finding.Cve;
}
private static JsonObject BuildLinks(HttpContext context, ReportDocumentDto document)
{
var links = new JsonObject();
if (context.Request.Host.HasValue)
{
var scheme = string.IsNullOrWhiteSpace(context.Request.Scheme) ? "https" : context.Request.Scheme;
var builder = new UriBuilder(scheme, context.Request.Host.Host)
{
Port = context.Request.Host.Port ?? -1,
Path = $"/ui/reports/{Uri.EscapeDataString(document.ReportId)}"
};
links["ui"] = builder.Uri.ToString();
}
return links;
}
private static string MapVerdict(string verdict)
=> verdict.ToLowerInvariant() switch
{
"blocked" or "fail" => "fail",
"escalated" => "fail",
"warn" or "warned" or "deferred" or "requiresvex" => "warn",
_ => "pass"
};
}
internal static class ReportEventDispatcherExtensions
{
public static void RemoveNulls(this JsonObject jsonObject)
{
if (jsonObject is null)
{
return;
}
var keysToRemove = new List<string>();
foreach (var pair in jsonObject)
{
if (pair.Value is null || pair.Value.GetValueKind() == JsonValueKind.Null)
{
keysToRemove.Add(pair.Key);
}
}
foreach (var key in keysToRemove)
{
jsonObject.Remove(key);
}
}
}

View File

@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Services;
public interface IReportSigner : IDisposable
{
ReportSignature? Sign(ReadOnlySpan<byte> payload);
}
public sealed class ReportSigner : IReportSigner
{
private enum SigningMode
{
Disabled,
Provider,
Hs256
}
private readonly SigningMode mode;
private readonly string keyId = string.Empty;
private readonly string algorithmName = string.Empty;
private readonly ILogger<ReportSigner> logger;
private readonly ICryptoProviderRegistry cryptoRegistry;
private readonly ICryptoProvider? provider;
private readonly CryptoKeyReference? keyReference;
private readonly CryptoSignerResolution? signerResolution;
private readonly byte[]? hmacKey;
public ReportSigner(
IOptions<ScannerWebServiceOptions> options,
ICryptoProviderRegistry cryptoRegistry,
ILogger<ReportSigner> logger)
{
ArgumentNullException.ThrowIfNull(options);
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
var value = options.Value ?? new ScannerWebServiceOptions();
var features = value.Features ?? new ScannerWebServiceOptions.FeatureFlagOptions();
var signing = value.Signing ?? new ScannerWebServiceOptions.SigningOptions();
if (!features.EnableSignedReports || !signing.Enabled)
{
mode = SigningMode.Disabled;
logger.LogInformation("Report signing disabled (feature flag or signing.enabled=false).");
return;
}
if (string.IsNullOrWhiteSpace(signing.KeyId))
{
throw new InvalidOperationException("Signing keyId must be configured when signing is enabled.");
}
var keyPem = ResolveKeyMaterial(signing);
keyId = signing.KeyId.Trim();
var resolvedMode = ResolveSigningMode(signing.Algorithm, out var canonicalAlgorithm, out var joseAlgorithm);
algorithmName = joseAlgorithm;
switch (resolvedMode)
{
case SigningMode.Provider:
{
provider = ResolveProvider(signing.Provider, canonicalAlgorithm);
var privateKey = DecodeKey(keyPem);
var reference = new CryptoKeyReference(keyId, provider.Name);
var signingKeyDescriptor = new CryptoSigningKey(
reference,
canonicalAlgorithm,
privateKey,
createdAt: DateTimeOffset.UtcNow);
provider.UpsertSigningKey(signingKeyDescriptor);
signerResolution = cryptoRegistry.ResolveSigner(
CryptoCapability.Signing,
canonicalAlgorithm,
reference,
provider.Name);
keyReference = reference;
mode = SigningMode.Provider;
break;
}
case SigningMode.Hs256:
{
hmacKey = DecodeKey(keyPem);
mode = SigningMode.Hs256;
break;
}
default:
mode = SigningMode.Disabled;
break;
}
}
public ReportSignature? Sign(ReadOnlySpan<byte> payload)
{
if (mode == SigningMode.Disabled)
{
return null;
}
if (payload.IsEmpty)
{
throw new ArgumentException("Payload must be non-empty.", nameof(payload));
}
return mode switch
{
SigningMode.Provider => SignWithProvider(payload),
SigningMode.Hs256 => SignHs256(payload),
_ => null
};
}
private ReportSignature SignWithProvider(ReadOnlySpan<byte> payload)
{
var resolution = signerResolution ?? throw new InvalidOperationException("Signing provider has not been initialised.");
var signature = resolution.Signer
.SignAsync(payload.ToArray())
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
return new ReportSignature(keyId, algorithmName, Convert.ToBase64String(signature));
}
private ReportSignature SignHs256(ReadOnlySpan<byte> payload)
{
if (hmacKey is null)
{
throw new InvalidOperationException("HMAC signing has not been initialised.");
}
using var hmac = new HMACSHA256(hmacKey);
var signature = hmac.ComputeHash(payload.ToArray());
return new ReportSignature(keyId, algorithmName, Convert.ToBase64String(signature));
}
public void Dispose()
{
if (provider is not null && keyReference is not null)
{
provider.RemoveSigningKey(keyReference.KeyId);
}
}
private ICryptoProvider ResolveProvider(string? configuredProvider, string canonicalAlgorithm)
{
if (!string.IsNullOrWhiteSpace(configuredProvider))
{
if (!cryptoRegistry.TryResolve(configuredProvider.Trim(), out var hinted))
{
throw new InvalidOperationException($"Configured signing provider '{configuredProvider}' is not registered.");
}
if (!hinted.Supports(CryptoCapability.Signing, canonicalAlgorithm))
{
throw new InvalidOperationException($"Provider '{configuredProvider}' does not support algorithm '{canonicalAlgorithm}'.");
}
return hinted;
}
return cryptoRegistry.ResolveOrThrow(CryptoCapability.Signing, canonicalAlgorithm);
}
private static SigningMode ResolveSigningMode(string? algorithm, out string canonicalAlgorithm, out string joseAlgorithm)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
throw new InvalidOperationException("Signing algorithm must be specified when signing is enabled.");
}
switch (algorithm.Trim().ToLowerInvariant())
{
case "ed25519":
case "eddsa":
canonicalAlgorithm = SignatureAlgorithms.Ed25519;
joseAlgorithm = SignatureAlgorithms.EdDsa;
return SigningMode.Provider;
case "hs256":
canonicalAlgorithm = "HS256";
joseAlgorithm = "HS256";
return SigningMode.Hs256;
default:
throw new InvalidOperationException($"Unsupported signing algorithm '{algorithm}'.");
}
}
private static string ResolveKeyMaterial(ScannerWebServiceOptions.SigningOptions signing)
{
if (!string.IsNullOrWhiteSpace(signing.KeyPem))
{
return signing.KeyPem;
}
if (!string.IsNullOrWhiteSpace(signing.KeyPemFile))
{
try
{
return File.ReadAllText(signing.KeyPemFile);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Unable to read signing key file '{signing.KeyPemFile}'.", ex);
}
}
throw new InvalidOperationException("Signing keyPem must be configured when signing is enabled.");
}
private static byte[] DecodeKey(string keyMaterial)
{
if (string.IsNullOrWhiteSpace(keyMaterial))
{
throw new InvalidOperationException("Signing key material is empty.");
}
var segments = keyMaterial.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var builder = new StringBuilder();
var hadPemMarkers = false;
foreach (var segment in segments)
{
var trimmed = segment.Trim();
if (trimmed.Length == 0)
{
continue;
}
if (trimmed.StartsWith("-----", StringComparison.Ordinal))
{
hadPemMarkers = true;
continue;
}
builder.Append(trimmed);
}
var base64 = hadPemMarkers ? builder.ToString() : keyMaterial.Trim();
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Signing key must be Base64 encoded.", ex);
}
}
}
public sealed record ReportSignature(string KeyId, string Algorithm, string Signature);

View File

@@ -12,6 +12,7 @@
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
@@ -20,5 +21,11 @@
<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>

View File

@@ -4,12 +4,13 @@
|----|--------|----------|------------|-------------|---------------|
| 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-103 | DONE (2025-10-19) | 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-POLICY-09-105 | DONE (2025-10-19) | 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 | 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. | Webhook integration test passes; responses include verdict, TTL, reasons; metrics/logging added. |
| SCANNER-EVENTS-15-201 | TODO | 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 | 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-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. |