Restructure solution layout by module
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
root
2025-10-28 15:10:40 +02:00
parent 4e3e575db5
commit 68da90a11a
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,21 @@
# Deno Analyzer Task Board
> **Imposed rule:** work of this type or tasks of this type on this component — and everywhere else it should be applied.
## Deno Entry-Point Analyzer (Sprint 49)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-DENO-26-001 | TODO | Deno Analyzer Guild | SCANNER-ANALYZERS-LANG-10-309N | Build input normalizer & VFS for Deno projects: merge `deno.json(c)`, import maps, lockfiles, vendor dirs, `$DENO_DIR` caches, and container layers. Detect runtime/toolchain hints deterministically. | Normalizer ingests fixtures (source+vendor, cache-only, container) without network; outputs config digest, import map, cache locations, and deterministic module root inventory. |
| SCANNER-ANALYZERS-DENO-26-002 | TODO | Deno Analyzer Guild | SCANNER-ANALYZERS-DENO-26-001 | Module graph builder: resolve static/dynamic imports using import map, `deno.lock`, vendor/, cache, npm bridge, node: builtins, WASM/JSON assertions. Annotate edges with resolution source and form. | Graph reconstruction succeeds on fixtures (vendor, cache-only, npm, remote). Edges include `form`, `source`, `resolution` (`vendor|cache|fs|declared_only`). Determinism harness passes. |
| SCANNER-ANALYZERS-DENO-26-003 | TODO | Deno Analyzer Guild | SCANNER-ANALYZERS-DENO-26-002 | NPM/Node compat adapter: map `npm:` specifiers to cached packages or compat `node_modules`, evaluate package `exports`/conditions, record node: builtin usage. | Fixtures with npm bridge resolve to cached/vendor modules; outputs include npm package metadata + node builtin list; unresolved npm deps flagged. |
| SCANNER-ANALYZERS-DENO-26-004 | TODO | Deno Analyzer Guild | SCANNER-ANALYZERS-DENO-26-002 | Static analyzer for permission/capability signals (FS, net, env, process, crypto, FFI, workers). Detect dynamic-import patterns, literal fetch URLs, tasks vs declared permissions. | Capability records emitted with evidence snippets; dynamic import warnings include pattern info; task vs inferred permission diffs reported. |
| SCANNER-ANALYZERS-DENO-26-005 | TODO | Deno Analyzer Guild | SCANNER-ANALYZERS-DENO-26-002 | Bundle/binary inspector: parse eszip bundles and `deno compile` executables (embedded eszip + snapshot) to recover module graph, config, embedded resources. | Bundle and compile fixtures yield recovered module lists, digests, and target metadata; compiled exe scanning <600ms; determinism verified. |
| SCANNER-ANALYZERS-DENO-26-006 | TODO | Deno Analyzer Guild | SCANNER-ANALYZERS-DENO-26-002 | Container adapter: traverse OCI layers for `deno`, caches, vendor directories, compiled binaries; merge module provenance with layer info. | Container fixtures output runtime version, cache roots, vendor mapping, binary metadata with layer provenance; determinism maintained. |
## Deno Observation & Runtime (Sprint 50)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-DENO-26-007 | TODO | Deno Analyzer Guild | SCANNER-ANALYZERS-DENO-26-002 | Produce AOC-compliant observations: entrypoints, modules, edges, permissions, workers, warnings, binaries with reason codes and contexts. | Observation JSON for fixtures deterministic; edges include form/source/reason; capabilities and permission drift recorded; passes AOC lint. |
| SCANNER-ANALYZERS-DENO-26-008 | TODO | Deno Analyzer Guild, QA Guild | SCANNER-ANALYZERS-DENO-26-007 | Fixture suite + performance benchmarks (vendor, npm, FFI, workers, dynamic import, bundle/binary, cache-only, container). | Fixture set under `fixtures/lang/deno/ep`; determinism and perf (<1.5s 2k-module graph) CI gates enabled. |
| SCANNER-ANALYZERS-DENO-26-009 | TODO | Deno Analyzer Guild, Signals Guild | SCANNER-ANALYZERS-DENO-26-007 | Optional runtime evidence hooks (loader/require shim) capturing module loads + permissions during harnessed execution with path hashing. | Runtime harness logs module loads for sample app with scrubbed paths; runtime edges merge without altering static precedence; privacy doc updated. |
| SCANNER-ANALYZERS-DENO-26-010 | TODO | Deno Analyzer Guild, DevOps Guild | SCANNER-ANALYZERS-DENO-26-007 | Package analyzer plug-in, add CLI (`stella deno inspect|resolve|trace`) commands, update Offline Kit docs, ensure Worker integration. | Plug-in manifest deployed; CLI commands documented/tested; Offline Kit instructions updated; worker restart verified. |
| SCANNER-ANALYZERS-DENO-26-011 | TODO | Deno Analyzer Guild | SCANNER-ANALYZERS-DENO-26-004 | Policy signal emitter: net/fs/env/ffi/process/crypto capabilities, remote origin list, npm usage, wasm modules, dynamic-import warnings. | Outputs include policy signal section consumed by tests; schema documented; sample policy evaluation validated. |

View File

@@ -0,0 +1,22 @@
# PHP Analyzer Task Board
> **Imposed rule:** work of this type or tasks of this type on this component — and everywhere else it should be applied.
## PHP Entry-Point Analyzer (Sprint 51)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-PHP-27-001 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-LANG-10-309P | Build input normalizer & VFS for PHP projects: merge source trees, composer manifests, vendor/, php.ini/conf.d, `.htaccess`, FPM configs, container layers. Detect framework/CMS fingerprints deterministically. | Normalizer ingests fixtures (Laravel, Symfony, WordPress, Drupal, container) without network; outputs config inventory, framework tags, and deterministic module/vendor root list. |
| SCANNER-ANALYZERS-PHP-27-002 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-PHP-27-001 | Composer/Autoload analyzer: parse composer.json/lock/installed.json, generate package nodes, autoload edges (psr-4/0/classmap/files), bin entrypoints, composer plugins. | Composer fixtures produce package list with PURLs, autoload graph, bin scripts, plugin inventory; determinism harness passes. |
| SCANNER-ANALYZERS-PHP-27-003 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-PHP-27-002 | Include/require graph builder: resolve static includes, capture dynamic include patterns, bootstrap chains, merge with autoload edges. | Include graph constructed for fixtures (legacy, WordPress, Laravel); dynamic includes recorded with patterns; deterministic ordering ensured. |
| SCANNER-ANALYZERS-PHP-27-004 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-PHP-27-003 | Runtime capability scanner: detect exec/fs/net/env/serialization/crypto/database usage, stream wrappers, uploads; record evidence snippets. | Capability signals generated for fixtures (exec, curl, unserialize); outputs include file/line/evidence hash; determinism validated. |
| SCANNER-ANALYZERS-PHP-27-005 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-PHP-27-001 | PHAR/Archive inspector: parse phar manifests/stubs, hash files, detect embedded vendor trees and phar:// usage. | PHAR fixtures yield file inventory, signature metadata, autoload edges; deterministic parse under <800 ms. |
| SCANNER-ANALYZERS-PHP-27-006 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-PHP-27-001 | Framework/CMS surface mapper: extract routes, controllers, middleware, CLI/cron entrypoints for Laravel/Symfony/Slim/WordPress/Drupal/Magento. | Framework fixtures produce route/endpoint lists, CLI command inventory, cron hints; tests validate coverage. |
| SCANNER-ANALYZERS-PHP-27-007 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-PHP-27-001 | Container & extension detector: parse php.ini/conf.d, map extensions to .so/.dll, collect web server/FPM settings, upload limits, disable_functions. | Container fixture outputs extension list with file paths, php.ini directives, web server front controller data; determinism maintained. |
## PHP Observation & Runtime (Sprint 52)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-PHP-27-008 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-PHP-27-002 | Produce AOC-compliant observations: entrypoints, packages, extensions, modules, edges (require/autoload), capabilities, routes, configs. | Observation JSON for fixtures deterministic; edges contain reason/form; capability and route inventories included; passes AOC lint. |
| SCANNER-ANALYZERS-PHP-27-009 | TODO | PHP Analyzer Guild, QA Guild | SCANNER-ANALYZERS-PHP-27-008 | Fixture suite + performance benchmarks (Laravel, Symfony, WordPress, legacy, PHAR, container) with golden outputs. | Fixture set under `fixtures/lang/php/ep`; determinism and perf (<4s 50k files) gates active. |
| SCANNER-ANALYZERS-PHP-27-010 | TODO | PHP Analyzer Guild, Signals Guild | SCANNER-ANALYZERS-PHP-27-008 | Optional runtime evidence hooks (if provided) to ingest audit logs or opcode cache stats with path hashing. | Runtime harness (if supplied) integrates without altering static precedence; hashed paths; documentation updated. |
| SCANNER-ANALYZERS-PHP-27-011 | TODO | PHP Analyzer Guild, DevOps Guild | SCANNER-ANALYZERS-PHP-27-008 | Package analyzer plug-in, add CLI (`stella php inspect|resolve`) commands, update Offline Kit docs, ensure Worker integration. | Plug-in manifest deployed; CLI commands documented/tested; Offline Kit instructions updated; worker restart verified. |
| SCANNER-ANALYZERS-PHP-27-012 | TODO | PHP Analyzer Guild | SCANNER-ANALYZERS-PHP-27-004 | Policy signal emitter: extension requirements/presence, dangerous constructs counters, stream wrapper usage, capability summaries. | Policy signal section emitted and validated against fixtures; schema documented; sample policy evaluation added. |

View File

@@ -0,0 +1,22 @@
# Ruby Analyzer Task Board
> **Imposed rule:** work of this type or tasks of this type on this component — and everywhere else it should be applied.
## Ruby Entry-Point Analyzer (Sprint 53)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-RUBY-28-001 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-LANG-10-309R | Build input normalizer & VFS for Ruby projects: merge source trees, Gemfile/Gemfile.lock, vendor/bundle, .gem archives, `.bundle/config`, Rack configs, containers. Detect framework/job fingerprints deterministically. | Normalizer ingests fixtures (Rails, Rack, Sinatra, Sidekiq, container) without network; outputs config inventory, framework tags, ruby version hints, deterministic gem/vendor root list. |
| SCANNER-ANALYZERS-RUBY-28-002 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-RUBY-28-001 | Gem & Bundler analyzer: parse Gemfile/Gemfile.lock, vendor specs, .gem archives, produce package nodes (PURLs), dependency edges, bin scripts, Bundler group metadata. | Fixtures produce package list with version, groups, path/git sources; .gem archives decoded safely; determinism harness passes. |
| SCANNER-ANALYZERS-RUBY-28-003 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-RUBY-28-002 | Require/autoload graph builder: resolve static/dynamic require, require_relative, load; infer Zeitwerk autoload paths and Rack boot chain. | Require graph built for fixtures (Rails, Rack, legacy); dynamic require warnings recorded; zeitwerk edges generated; deterministic ordering ensured. |
| SCANNER-ANALYZERS-RUBY-28-004 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-RUBY-28-001 | Framework surface mapper: extract routes/controllers/middleware for Rails/Rack/Sinatra/Grape/Hanami; inventory jobs/schedulers (Sidekiq, Resque, ActiveJob, whenever, clockwork). | Framework fixtures emit route, controller, middleware, job, scheduler entries with provenance; tests validate coverage. |
| SCANNER-ANALYZERS-RUBY-28-005 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-RUBY-28-003 | Capability analyzer: detect os-exec, filesystem, network, serialization, crypto, DB usage, TLS posture, dynamic eval; record evidence snippets with file/line. | Capability signals generated for fixtures (system, Net::HTTP, YAML.load, exec); outputs deterministic with hashed snippets. |
| SCANNER-ANALYZERS-RUBY-28-006 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-RUBY-28-001 | Rake task & scheduler analyzer: parse Rakefiles/lib/tasks, capture task names/prereqs/shell commands; parse Sidekiq/whenever/clockwork configs into schedules. | Task/scheduler inventory produced for fixtures; includes cron specs, shell commands; determinism confirmed. |
| SCANNER-ANALYZERS-RUBY-28-007 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-RUBY-28-001 | Container/runtime scanner: detect Ruby version, installed gems, native extensions, web server configs in OCI layers. | Container fixtures output ruby version, gem list, native extension paths, server configs; determinism maintained. |
## Ruby Observation & Runtime (Sprint 54)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-RUBY-28-008 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-RUBY-28-002 | Produce AOC-compliant observations: entrypoints, packages, modules, edges (require/autoload), routes, jobs, tasks, capabilities, configs, warnings. | Observation JSON for fixtures deterministic; edges include reason/form; capability/route/task inventories present; passes AOC lint. |
| SCANNER-ANALYZERS-RUBY-28-009 | TODO | Ruby Analyzer Guild, QA Guild | SCANNER-ANALYZERS-RUBY-28-008 | Fixture suite + performance benchmarks (Rails, Rack, Sinatra, Sidekiq, legacy, .gem, container) with golden outputs. | Fixture set under `fixtures/lang/ruby/ep`; determinism & perf (<4.5s 40k files) CI guard active. |
| SCANNER-ANALYZERS-RUBY-28-010 | TODO | Ruby Analyzer Guild, Signals Guild | SCANNER-ANALYZERS-RUBY-28-008 | Optional runtime evidence integration (if provided logs/metrics) with path hashing, without altering static precedence. | Runtime harness logs merge cleanly with static graph; hashed paths ensure privacy; documentation updated. |
| SCANNER-ANALYZERS-RUBY-28-011 | TODO | Ruby Analyzer Guild, DevOps Guild | SCANNER-ANALYZERS-RUBY-28-008 | Package analyzer plug-in, add CLI (`stella ruby inspect|resolve`) commands, update Offline Kit docs, ensure Worker integration. | Plugin manifest deployed; CLI commands documented/tested; Offline Kit instructions updated; worker restart verified. |
| SCANNER-ANALYZERS-RUBY-28-012 | TODO | Ruby Analyzer Guild | SCANNER-ANALYZERS-RUBY-28-005 | Policy signal emitter: rubygems drift, native extension flags, dangerous constructs counts, TLS verify posture, dynamic require eval warnings. | Policy signal section emitted and validated against fixtures; schema documented; sample policy evaluation added. |

View File

@@ -0,0 +1,20 @@
# Native Analyzer Task Board
> **Imposed rule:** work of this type or tasks of this type on this component — and everywhere else it should be applied.
## Native Static Analyzer (Sprint 37)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-NATIVE-20-001 | TODO | Native Analyzer Guild | SCANNER-CORE-09-501 | Implement format detector and binary identity model supporting ELF, PE/COFF, and Mach-O (including fat slices). Capture arch, OS, build-id/UUID, interpreter metadata. | Detector recognises sample binaries across linux/windows/macos; entrypoint identity includes arch+os slice and stable hash; fixtures stored under `fixtures/native/format-detector`. |
| SCANNER-ANALYZERS-NATIVE-20-002 | TODO | Native Analyzer Guild | SCANNER-ANALYZERS-NATIVE-20-001 | Parse ELF dynamic sections: `DT_NEEDED`, `DT_RPATH`, `DT_RUNPATH`, symbol versions, interpreter, and note build-id. Emit declared dependency records with reason `elf-dtneeded` and attach version needs. | ELF fixtures (glibc, musl, Go static) produce deterministic dependency records with runpath/rpath metadata and symbol version needs. |
| SCANNER-ANALYZERS-NATIVE-20-003 | TODO | Native Analyzer Guild | SCANNER-ANALYZERS-NATIVE-20-001 | Parse PE imports, delay-load tables, manifests/SxS metadata, and subsystem flags. Emit edges with reasons `pe-import` and `pe-delayimport`, plus SxS policy metadata. | Windows fixtures (standard, delay-load, SxS) generate dependency edges with policy hashes and delay-load markers; unit tests validate manifest parsing. |
| SCANNER-ANALYZERS-NATIVE-20-004 | TODO | Native Analyzer Guild | SCANNER-ANALYZERS-NATIVE-20-001 | Parse Mach-O load commands (`LC_LOAD_DYLIB`, `LC_REEXPORT_DYLIB`, `LC_RPATH`, `LC_UUID`, fat headers). Handle `@rpath/@loader_path` placeholders and slice separation. | Mach-O fixtures (single + universal) emit dependency edges per slice with expanded paths and UUID metadata; tests confirm `@rpath` expansion order. |
| SCANNER-ANALYZERS-NATIVE-20-005 | TODO | Native Analyzer Guild | SCANNER-ANALYZERS-NATIVE-20-002, SCANNER-ANALYZERS-NATIVE-20-003, SCANNER-ANALYZERS-NATIVE-20-004 | Implement resolver engine modeling loader search order for ELF (rpath/runpath/cache/default), PE (SafeDll search + SxS), and Mach-O (`@rpath` expansion). Works against virtual image roots, producing explain traces. | Resolver passes golden tests across linux/windows/macos fixtures; resolution trace records attempted paths; no host filesystem access in tests. |
| SCANNER-ANALYZERS-NATIVE-20-006 | TODO | Native Analyzer Guild | SCANNER-ANALYZERS-NATIVE-20-005 | Build heuristic scanner for `dlopen`/`LoadLibrary` strings, plugin ecosystem configs, and Go/Rust static hints. Emit edges with `reason_code` (`string-dlopen`, `config-plugin`, `ecosystem-heuristic`) and confidence levels. | Heuristic edges appear in fixtures (nginx modules, dlopen string literals); confidence flags applied; explain metadata references source string/config path. |
## Native Observation Pipeline (Sprint 38)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-NATIVE-20-007 | TODO | Native Analyzer Guild, SBOM Service Guild | SCANNER-ANALYZERS-NATIVE-20-005 | Serialize AOC-compliant observations: entrypoints + dependency edges + environment profiles (search paths, interpreter, loader metadata). Integrate with Scanner writer API. | Analyzer emits normalized `entrypoints[]`/`edges[]` JSON for fixtures; SBOM tests consume output; determinism harness updated. |
| SCANNER-ANALYZERS-NATIVE-20-008 | TODO | Native Analyzer Guild, QA Guild | SCANNER-ANALYZERS-NATIVE-20-007 | Author cross-platform fixtures (ELF dynamic/static, PE delay-load/SxS, Mach-O @rpath, plugin configs) and determinism benchmarks (<25 ms / binary, <250 MB). | Fixture suite committed; determinism CI passes; benchmark report documents perf budgets and regression guard rails. |
| SCANNER-ANALYZERS-NATIVE-20-009 | TODO | Native Analyzer Guild, Signals Guild | SCANNER-ANALYZERS-NATIVE-20-007 | Provide optional runtime capture adapters (Linux eBPF `dlopen`, Windows ETW ImageLoad, macOS dyld interpose) writing append-only runtime evidence. Include redaction/sandbox guidance. | Runtime harness emits `runtime-load` edges for sample binaries; data scrubbed to image-relative paths; docs outline sandboxing and privacy. |
| SCANNER-ANALYZERS-NATIVE-20-010 | TODO | Native Analyzer Guild, DevOps Guild | SCANNER-ANALYZERS-NATIVE-20-007 | Package native analyzer as restart-time plug-in with manifest/DI registration; update Offline Kit bundle + documentation. | Plugin manifest copied to `plugins/scanner/analyzers/native/`; Worker loads analyzer on restart; Offline Kit instructions updated; smoke test verifies packaging. |

View File

@@ -0,0 +1,12 @@
# StellaOps.Scanner.Sbomer.BuildXPlugin — Agent Charter
## Mission
Implement the build-time SBOM generator described in `docs/ARCHITECTURE_SCANNER.md` and new buildx dossier requirements:
- Provide a deterministic BuildKit/Buildx generator that produces layer SBOM fragments and uploads them to local CAS.
- Emit OCI annotations (+provenance) compatible with Scanner.Emit and Attestor hand-offs.
- Respect restart-time plug-in policy (`plugins/scanner/buildx/` manifests) and keep CI overhead ≤300ms per layer.
## Expectations
- Read architecture + upcoming Buildx addendum before coding.
- Ensure graceful fallback to post-build scan when generator unavailable.
- Provide integration tests with mock BuildKit, and update `TASKS.md` as states change.

View File

@@ -0,0 +1,49 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
/// <summary>
/// Sends provenance placeholders to the Attestor service for asynchronous DSSE signing.
/// </summary>
public sealed class AttestorClient
{
private readonly HttpClient httpClient;
public AttestorClient(HttpClient httpClient)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task SendPlaceholderAsync(Uri attestorUri, DescriptorDocument document, CancellationToken cancellationToken)
{
if (attestorUri is null)
{
throw new ArgumentNullException(nameof(attestorUri));
}
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
var payload = new AttestorProvenanceRequest(
ImageDigest: document.Subject.Digest,
SbomDigest: document.Artifact.Digest,
ExpectedDsseSha256: document.Provenance.ExpectedDsseSha256,
Nonce: document.Provenance.Nonce,
PredicateType: document.Provenance.PredicateType,
Schema: document.Schema);
using var response = await httpClient.PostAsJsonAsync(attestorUri, payload, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new BuildxPluginException($"Attestor rejected provenance placeholder ({(int)response.StatusCode}): {body}");
}
}
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
public sealed record AttestorProvenanceRequest(
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("sbomDigest")] string SbomDigest,
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
[property: JsonPropertyName("nonce")] string Nonce,
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("schema")] string Schema);

View File

@@ -0,0 +1,19 @@
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
/// <summary>
/// Represents user-facing errors raised by the BuildX plug-in.
/// </summary>
public sealed class BuildxPluginException : Exception
{
public BuildxPluginException(string message)
: base(message)
{
}
public BuildxPluginException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Result of persisting bytes into the local CAS.
/// </summary>
public sealed record CasWriteResult(string Algorithm, string Digest, string Path);

View File

@@ -0,0 +1,74 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Minimal filesystem-backed CAS used when the BuildX generator runs inside CI.
/// </summary>
public sealed class LocalCasClient
{
private readonly string rootDirectory;
private readonly string algorithm;
public LocalCasClient(LocalCasOptions options)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
algorithm = options.Algorithm.ToLowerInvariant();
if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("Only the sha256 algorithm is supported.", nameof(options));
}
rootDirectory = Path.GetFullPath(options.RootDirectory);
}
public Task<CasWriteResult> VerifyWriteAsync(CancellationToken cancellationToken)
{
ReadOnlyMemory<byte> probe = "stellaops-buildx-probe"u8.ToArray();
return WriteAsync(probe, cancellationToken);
}
public async Task<CasWriteResult> WriteAsync(ReadOnlyMemory<byte> content, CancellationToken cancellationToken)
{
var digest = ComputeDigest(content.Span);
var path = BuildObjectPath(digest);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
bufferSize: 16 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
await stream.WriteAsync(content, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
return new CasWriteResult(algorithm, digest, path);
}
private string BuildObjectPath(string digest)
{
// Layout: <root>/<algorithm>/<first two>/<rest>.bin
var prefix = digest.Substring(0, 2);
var suffix = digest[2..];
return Path.Combine(rootDirectory, algorithm, prefix, $"{suffix}.bin");
}
private static string ComputeDigest(ReadOnlySpan<byte> content)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(content, buffer);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,40 @@
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Configuration for the on-disk content-addressable store used during CI.
/// </summary>
public sealed record LocalCasOptions
{
private string rootDirectory = string.Empty;
private string algorithm = "sha256";
public string RootDirectory
{
get => rootDirectory;
init
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Root directory must be provided.", nameof(value));
}
rootDirectory = value;
}
}
public string Algorithm
{
get => algorithm;
init
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Algorithm must be provided.", nameof(value));
}
algorithm = value;
}
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Represents an OCI artifact descriptor emitted by the BuildX generator.
/// </summary>
public sealed record DescriptorArtifact(
[property: JsonPropertyName("mediaType")] string MediaType,
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string> Annotations);

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Root payload describing BuildX generator output with provenance placeholders.
/// </summary>
public sealed record DescriptorDocument(
[property: JsonPropertyName("schema")] string Schema,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("generator")] DescriptorGeneratorMetadata Generator,
[property: JsonPropertyName("subject")] DescriptorSubject Subject,
[property: JsonPropertyName("artifact")] DescriptorArtifact Artifact,
[property: JsonPropertyName("provenance")] DescriptorProvenance Provenance,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);

View File

@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Builds immutable OCI descriptors enriched with provenance placeholders.
/// </summary>
public sealed class DescriptorGenerator
{
public const string Schema = "stellaops.buildx.descriptor.v1";
private readonly TimeProvider timeProvider;
public DescriptorGenerator(TimeProvider timeProvider)
{
timeProvider ??= TimeProvider.System;
this.timeProvider = timeProvider;
}
public async Task<DescriptorDocument> CreateAsync(DescriptorRequest request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
throw new BuildxPluginException("Image digest must be provided.");
}
if (string.IsNullOrWhiteSpace(request.SbomPath))
{
throw new BuildxPluginException("SBOM path must be provided.");
}
var sbomFile = new FileInfo(request.SbomPath);
if (!sbomFile.Exists)
{
throw new BuildxPluginException($"SBOM file '{request.SbomPath}' was not found.");
}
var sbomDigest = await ComputeFileDigestAsync(sbomFile, cancellationToken).ConfigureAwait(false);
var nonce = ComputeDeterministicNonce(request, sbomFile, sbomDigest);
var expectedDsseSha = ComputeExpectedDsseDigest(request.ImageDigest, sbomDigest, nonce);
var artifactAnnotations = BuildArtifactAnnotations(request, nonce, expectedDsseSha);
var subject = new DescriptorSubject(
MediaType: request.SubjectMediaType,
Digest: request.ImageDigest);
var artifact = new DescriptorArtifact(
MediaType: request.SbomMediaType,
Digest: sbomDigest,
Size: sbomFile.Length,
Annotations: artifactAnnotations);
var provenance = new DescriptorProvenance(
Status: "pending",
ExpectedDsseSha256: expectedDsseSha,
Nonce: nonce,
AttestorUri: request.AttestorUri,
PredicateType: request.PredicateType);
var generatorMetadata = new DescriptorGeneratorMetadata(
Name: request.GeneratorName ?? "StellaOps.Scanner.Sbomer.BuildXPlugin",
Version: request.GeneratorVersion);
var metadata = BuildDocumentMetadata(request, sbomFile, sbomDigest);
return new DescriptorDocument(
Schema: Schema,
GeneratedAt: timeProvider.GetUtcNow(),
Generator: generatorMetadata,
Subject: subject,
Artifact: artifact,
Provenance: provenance,
Metadata: metadata);
}
private static string ComputeDeterministicNonce(DescriptorRequest request, FileInfo sbomFile, string sbomDigest)
{
var builder = new StringBuilder();
builder.AppendLine("stellaops.buildx.nonce.v1");
builder.AppendLine(request.ImageDigest);
builder.AppendLine(sbomDigest);
builder.AppendLine(sbomFile.Length.ToString(CultureInfo.InvariantCulture));
builder.AppendLine(request.SbomMediaType);
builder.AppendLine(request.SbomFormat);
builder.AppendLine(request.SbomKind);
builder.AppendLine(request.SbomArtifactType);
builder.AppendLine(request.SubjectMediaType);
builder.AppendLine(request.GeneratorVersion);
builder.AppendLine(request.GeneratorName ?? string.Empty);
builder.AppendLine(request.LicenseId ?? string.Empty);
builder.AppendLine(request.SbomName ?? string.Empty);
builder.AppendLine(request.Repository ?? string.Empty);
builder.AppendLine(request.BuildRef ?? string.Empty);
builder.AppendLine(request.AttestorUri ?? string.Empty);
builder.AppendLine(request.PredicateType);
var payload = Encoding.UTF8.GetBytes(builder.ToString());
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(payload, hash);
Span<byte> nonceBytes = stackalloc byte[16];
hash[..16].CopyTo(nonceBytes);
return Convert.ToHexString(nonceBytes).ToLowerInvariant();
}
private static async Task<string> ComputeFileDigestAsync(FileInfo file, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
file.FullName,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 128 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = new byte[128 * 1024];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
{
hash.AppendData(buffer, 0, bytesRead);
}
var digest = hash.GetHashAndReset();
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
}
private static string ComputeExpectedDsseDigest(string imageDigest, string sbomDigest, string nonce)
{
var payload = $"{imageDigest}\n{sbomDigest}\n{nonce}";
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(bytes, hash);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static IReadOnlyDictionary<string, string> BuildArtifactAnnotations(DescriptorRequest request, string nonce, string expectedDsse)
{
var annotations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["org.opencontainers.artifact.type"] = request.SbomArtifactType,
["org.stellaops.scanner.version"] = request.GeneratorVersion,
["org.stellaops.sbom.kind"] = request.SbomKind,
["org.stellaops.sbom.format"] = request.SbomFormat,
["org.stellaops.provenance.status"] = "pending",
["org.stellaops.provenance.dsse.sha256"] = expectedDsse,
["org.stellaops.provenance.nonce"] = nonce
};
if (!string.IsNullOrWhiteSpace(request.LicenseId))
{
annotations["org.stellaops.license.id"] = request.LicenseId!;
}
if (!string.IsNullOrWhiteSpace(request.SbomName))
{
annotations["org.opencontainers.image.title"] = request.SbomName!;
}
if (!string.IsNullOrWhiteSpace(request.Repository))
{
annotations["org.stellaops.repository"] = request.Repository!;
}
return annotations;
}
private static IReadOnlyDictionary<string, string> BuildDocumentMetadata(DescriptorRequest request, FileInfo fileInfo, string sbomDigest)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["sbomDigest"] = sbomDigest,
["sbomPath"] = fileInfo.FullName,
["sbomMediaType"] = request.SbomMediaType,
["subjectMediaType"] = request.SubjectMediaType
};
if (!string.IsNullOrWhiteSpace(request.Repository))
{
metadata["repository"] = request.Repository!;
}
if (!string.IsNullOrWhiteSpace(request.BuildRef))
{
metadata["buildRef"] = request.BuildRef!;
}
if (!string.IsNullOrWhiteSpace(request.AttestorUri))
{
metadata["attestorUri"] = request.AttestorUri!;
}
return metadata;
}
}

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
public sealed record DescriptorGeneratorMetadata(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string Version);

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Provenance placeholders that the Attestor will fulfil post-build.
/// </summary>
public sealed record DescriptorProvenance(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
[property: JsonPropertyName("nonce")] string Nonce,
[property: JsonPropertyName("attestorUri")] string? AttestorUri,
[property: JsonPropertyName("predicateType")] string PredicateType);

View File

@@ -0,0 +1,45 @@
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Request for generating BuildX descriptor artifacts.
/// </summary>
public sealed record DescriptorRequest
{
public string ImageDigest { get; init; } = string.Empty;
public string SbomPath { get; init; } = string.Empty;
public string SbomMediaType { get; init; } = "application/vnd.cyclonedx+json";
public string SbomFormat { get; init; } = "cyclonedx-json";
public string SbomArtifactType { get; init; } = "application/vnd.stellaops.sbom.layer+json";
public string SbomKind { get; init; } = "inventory";
public string SubjectMediaType { get; init; } = "application/vnd.oci.image.manifest.v1+json";
public string GeneratorVersion { get; init; } = "0.0.0";
public string? GeneratorName { get; init; }
public string? LicenseId { get; init; }
public string? SbomName { get; init; }
public string? Repository { get; init; }
public string? BuildRef { get; init; }
public string? AttestorUri { get; init; }
public string PredicateType { get; init; } = "https://slsa.dev/provenance/v1";
public DescriptorRequest Validate()
{
if (string.IsNullOrWhiteSpace(ImageDigest))
{
throw new BuildxPluginException("Image digest is required.");
}
if (!ImageDigest.Contains(':', StringComparison.Ordinal))
{
throw new BuildxPluginException("Image digest must include the algorithm prefix, e.g. 'sha256:...'.");
}
if (string.IsNullOrWhiteSpace(SbomPath))
{
throw new BuildxPluginException("SBOM path is required.");
}
return this;
}
}

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
public sealed record DescriptorSubject(
[property: JsonPropertyName("mediaType")] string MediaType,
[property: JsonPropertyName("digest")] string Digest);

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Describes default Content Addressable Storage configuration for the plug-in.
/// </summary>
public sealed record BuildxPluginCas
{
[JsonPropertyName("protocol")]
public string Protocol { get; init; } = "filesystem";
[JsonPropertyName("defaultRoot")]
public string DefaultRoot { get; init; } = "cas";
[JsonPropertyName("compression")]
public string Compression { get; init; } = "zstd";
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Describes how the buildx plug-in executable should be invoked.
/// </summary>
public sealed record BuildxPluginEntryPoint
{
[JsonPropertyName("type")]
public string Type { get; init; } = "dotnet";
[JsonPropertyName("executable")]
public string Executable { get; init; } = string.Empty;
[JsonPropertyName("arguments")]
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Provides distribution information for the container image form-factor.
/// </summary>
public sealed record BuildxPluginImage
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("platforms")]
public IReadOnlyList<string> Platforms { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Canonical manifest describing a buildx generator plug-in.
/// </summary>
public sealed record BuildxPluginManifest
{
public const string CurrentSchemaVersion = "1.0";
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("displayName")]
public string DisplayName { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("entryPoint")]
public BuildxPluginEntryPoint EntryPoint { get; init; } = new();
[JsonPropertyName("requiresRestart")]
public bool RequiresRestart { get; init; } = true;
[JsonPropertyName("capabilities")]
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
[JsonPropertyName("cas")]
public BuildxPluginCas Cas { get; init; } = new();
[JsonPropertyName("image")]
public BuildxPluginImage? Image { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
[JsonIgnore]
public string? SourcePath { get; init; }
[JsonIgnore]
public string? SourceDirectory { get; init; }
}

View File

@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Loads buildx plug-in manifests from the restart-time plug-in directory.
/// </summary>
public sealed class BuildxPluginManifestLoader
{
public const string DefaultSearchPattern = "*.manifest.json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true
};
private readonly string manifestDirectory;
private readonly string searchPattern;
public BuildxPluginManifestLoader(string manifestDirectory, string? searchPattern = null)
{
if (string.IsNullOrWhiteSpace(manifestDirectory))
{
throw new ArgumentException("Manifest directory is required.", nameof(manifestDirectory));
}
this.manifestDirectory = Path.GetFullPath(manifestDirectory);
this.searchPattern = string.IsNullOrWhiteSpace(searchPattern)
? DefaultSearchPattern
: searchPattern;
}
/// <summary>
/// Loads all manifests in the configured directory.
/// </summary>
public async Task<IReadOnlyList<BuildxPluginManifest>> LoadAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(manifestDirectory))
{
return Array.Empty<BuildxPluginManifest>();
}
var manifests = new List<BuildxPluginManifest>();
foreach (var file in Directory.EnumerateFiles(manifestDirectory, searchPattern, SearchOption.TopDirectoryOnly))
{
if (IsHiddenPath(file))
{
continue;
}
var manifest = await DeserializeManifestAsync(file, cancellationToken).ConfigureAwait(false);
manifests.Add(manifest);
}
return manifests
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
/// <summary>
/// Loads the manifest with the specified identifier.
/// </summary>
public async Task<BuildxPluginManifest> LoadByIdAsync(string manifestId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(manifestId))
{
throw new ArgumentException("Manifest identifier is required.", nameof(manifestId));
}
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
var manifest = manifests.FirstOrDefault(m => string.Equals(m.Id, manifestId, StringComparison.OrdinalIgnoreCase));
if (manifest is null)
{
throw new BuildxPluginException($"Buildx plug-in manifest '{manifestId}' was not found in '{manifestDirectory}'.");
}
return manifest;
}
/// <summary>
/// Loads the first available manifest.
/// </summary>
public async Task<BuildxPluginManifest> LoadDefaultAsync(CancellationToken cancellationToken)
{
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
if (manifests.Count == 0)
{
throw new BuildxPluginException($"No buildx plug-in manifests were discovered under '{manifestDirectory}'.");
}
return manifests[0];
}
private static bool IsHiddenPath(string path)
{
var directory = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(directory))
{
var segment = Path.GetFileName(directory);
if (segment.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static async Task<BuildxPluginManifest> DeserializeManifestAsync(string file, CancellationToken cancellationToken)
{
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
BuildxPluginManifest? manifest;
try
{
manifest = await JsonSerializer.DeserializeAsync<BuildxPluginManifest>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (JsonException ex)
{
throw new BuildxPluginException($"Failed to parse manifest '{file}'.", ex);
}
if (manifest is null)
{
throw new BuildxPluginException($"Manifest '{file}' is empty or invalid.");
}
ValidateManifest(manifest, file);
var directory = Path.GetDirectoryName(file);
return manifest with
{
SourcePath = file,
SourceDirectory = directory
};
}
private static void ValidateManifest(BuildxPluginManifest manifest, string file)
{
if (!string.Equals(manifest.SchemaVersion, BuildxPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
{
throw new BuildxPluginException(
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{BuildxPluginManifest.CurrentSchemaVersion}'.");
}
if (string.IsNullOrWhiteSpace(manifest.Id))
{
throw new BuildxPluginException($"Manifest '{file}' must specify a non-empty 'id'.");
}
if (manifest.EntryPoint is null)
{
throw new BuildxPluginException($"Manifest '{file}' must specify an 'entryPoint'.");
}
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Executable))
{
throw new BuildxPluginException($"Manifest '{file}' must specify an executable entry point.");
}
if (!manifest.RequiresRestart)
{
throw new BuildxPluginException($"Manifest '{file}' must enforce restart-required activation.");
}
if (manifest.Cas is null)
{
throw new BuildxPluginException($"Manifest '{file}' must define CAS defaults.");
}
if (string.IsNullOrWhiteSpace(manifest.Cas.DefaultRoot))
{
throw new BuildxPluginException($"Manifest '{file}' must specify a CAS default root directory.");
}
}
}

View File

@@ -0,0 +1,327 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
internal static class Program
{
private static readonly JsonSerializerOptions ManifestPrintOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private static readonly JsonSerializerOptions DescriptorJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private static async Task<int> Main(string[] args)
{
using var cancellation = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cancellation.Cancel();
};
var command = args.Length > 0 ? args[0].ToLowerInvariant() : "handshake";
var commandArgs = args.Skip(1).ToArray();
try
{
return command switch
{
"handshake" => await RunHandshakeAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
"manifest" => await RunManifestAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
"descriptor" or "annotate" => await RunDescriptorAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
"version" => RunVersion(),
"help" or "--help" or "-h" => PrintHelp(),
_ => UnknownCommand(command)
};
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Operation cancelled.");
return 130;
}
catch (BuildxPluginException ex)
{
Console.Error.WriteLine(ex.Message);
return 2;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Unhandled error: {ex}");
return 1;
}
}
private static async Task<int> RunHandshakeAsync(string[] args, CancellationToken cancellationToken)
{
var manifestDirectory = ResolveManifestDirectory(args);
var loader = new BuildxPluginManifestLoader(manifestDirectory);
var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false);
var casRoot = ResolveCasRoot(args, manifest);
var casClient = new LocalCasClient(new LocalCasOptions
{
RootDirectory = casRoot,
Algorithm = "sha256"
});
var result = await casClient.VerifyWriteAsync(cancellationToken).ConfigureAwait(false);
Console.WriteLine($"handshake ok: {manifest.Id}@{manifest.Version} → {result.Algorithm}:{result.Digest}");
Console.WriteLine(result.Path);
return 0;
}
private static async Task<int> RunManifestAsync(string[] args, CancellationToken cancellationToken)
{
var manifestDirectory = ResolveManifestDirectory(args);
var loader = new BuildxPluginManifestLoader(manifestDirectory);
var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false);
var json = JsonSerializer.Serialize(manifest, ManifestPrintOptions);
Console.WriteLine(json);
return 0;
}
private static int RunVersion()
{
var assembly = Assembly.GetExecutingAssembly();
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "unknown";
Console.WriteLine(version);
return 0;
}
private static int PrintHelp()
{
Console.WriteLine("StellaOps BuildX SBOM generator");
Console.WriteLine("Usage:");
Console.WriteLine(" stellaops-buildx [handshake|manifest|descriptor|version]");
Console.WriteLine();
Console.WriteLine("Commands:");
Console.WriteLine(" handshake Probe the local CAS and ensure manifests are discoverable.");
Console.WriteLine(" manifest Print the resolved manifest JSON.");
Console.WriteLine(" descriptor Emit OCI descriptor + provenance placeholder for the provided SBOM.");
Console.WriteLine(" version Print the plug-in version.");
Console.WriteLine();
Console.WriteLine("Options:");
Console.WriteLine(" --manifest <path> Override the manifest directory.");
Console.WriteLine(" --cas <path> Override the CAS root directory.");
Console.WriteLine(" --image <digest> (descriptor) Image digest the SBOM belongs to.");
Console.WriteLine(" --sbom <path> (descriptor) Path to the SBOM file to describe.");
Console.WriteLine(" --attestor <url> (descriptor) Optional Attestor endpoint for provenance placeholders.");
Console.WriteLine(" --attestor-token <token> Bearer token for Attestor requests (or STELLAOPS_ATTESTOR_TOKEN).");
Console.WriteLine(" --attestor-insecure Skip TLS verification for Attestor requests (dev/test only).");
return 0;
}
private static int UnknownCommand(string command)
{
Console.Error.WriteLine($"Unknown command '{command}'. Use 'help' for usage.");
return 1;
}
private static string ResolveManifestDirectory(string[] args)
{
var explicitPath = GetOption(args, "--manifest")
?? Environment.GetEnvironmentVariable("STELLAOPS_BUILDX_MANIFEST_DIR");
if (!string.IsNullOrWhiteSpace(explicitPath))
{
return Path.GetFullPath(explicitPath);
}
var defaultDirectory = Path.Combine(AppContext.BaseDirectory, "plugins", "scanner", "buildx");
if (Directory.Exists(defaultDirectory))
{
return defaultDirectory;
}
return AppContext.BaseDirectory;
}
private static string ResolveCasRoot(string[] args, BuildxPluginManifest manifest)
{
var overrideValue = GetOption(args, "--cas")
?? Environment.GetEnvironmentVariable("STELLAOPS_SCANNER_CAS_ROOT");
if (!string.IsNullOrWhiteSpace(overrideValue))
{
return Path.GetFullPath(overrideValue);
}
var manifestDefault = manifest.Cas.DefaultRoot;
if (!string.IsNullOrWhiteSpace(manifestDefault))
{
if (Path.IsPathRooted(manifestDefault))
{
return Path.GetFullPath(manifestDefault);
}
var baseDirectory = manifest.SourceDirectory ?? AppContext.BaseDirectory;
return Path.GetFullPath(Path.Combine(baseDirectory, manifestDefault));
}
return Path.Combine(AppContext.BaseDirectory, "cas");
}
private static async Task<int> RunDescriptorAsync(string[] args, CancellationToken cancellationToken)
{
var imageDigest = RequireOption(args, "--image");
var sbomPath = RequireOption(args, "--sbom");
var sbomMediaType = GetOption(args, "--media-type") ?? "application/vnd.cyclonedx+json";
var sbomFormat = GetOption(args, "--sbom-format") ?? "cyclonedx-json";
var sbomKind = GetOption(args, "--sbom-kind") ?? "inventory";
var artifactType = GetOption(args, "--artifact-type") ?? "application/vnd.stellaops.sbom.layer+json";
var subjectMediaType = GetOption(args, "--subject-media-type") ?? "application/vnd.oci.image.manifest.v1+json";
var predicateType = GetOption(args, "--predicate-type") ?? "https://slsa.dev/provenance/v1";
var licenseId = GetOption(args, "--license-id") ?? Environment.GetEnvironmentVariable("STELLAOPS_LICENSE_ID");
var repository = GetOption(args, "--repository");
var buildRef = GetOption(args, "--build-ref");
var sbomName = GetOption(args, "--sbom-name") ?? Path.GetFileName(sbomPath);
var attestorUriText = GetOption(args, "--attestor") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_URL");
var attestorToken = GetOption(args, "--attestor-token") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_TOKEN");
var attestorInsecure = GetFlag(args, "--attestor-insecure")
|| string.Equals(Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_INSECURE"), "true", StringComparison.OrdinalIgnoreCase);
Uri? attestorUri = null;
if (!string.IsNullOrWhiteSpace(attestorUriText))
{
attestorUri = new Uri(attestorUriText, UriKind.Absolute);
}
var assembly = Assembly.GetExecutingAssembly();
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "0.0.0";
var request = new DescriptorRequest
{
ImageDigest = imageDigest,
SbomPath = sbomPath,
SbomMediaType = sbomMediaType,
SbomFormat = sbomFormat,
SbomKind = sbomKind,
SbomArtifactType = artifactType,
SubjectMediaType = subjectMediaType,
PredicateType = predicateType,
GeneratorVersion = version,
GeneratorName = assembly.GetName().Name,
LicenseId = licenseId,
SbomName = sbomName,
Repository = repository,
BuildRef = buildRef,
AttestorUri = attestorUri?.ToString()
}.Validate();
var generator = new DescriptorGenerator(TimeProvider.System);
var document = await generator.CreateAsync(request, cancellationToken).ConfigureAwait(false);
if (attestorUri is not null)
{
using var httpClient = CreateAttestorHttpClient(attestorUri, attestorToken, attestorInsecure);
var attestorClient = new AttestorClient(httpClient);
await attestorClient.SendPlaceholderAsync(attestorUri, document, cancellationToken).ConfigureAwait(false);
}
var json = JsonSerializer.Serialize(document, DescriptorJsonOptions);
Console.WriteLine(json);
return 0;
}
private static string? GetOption(string[] args, string optionName)
{
for (var i = 0; i < args.Length; i++)
{
var argument = args[i];
if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase))
{
if (i + 1 >= args.Length)
{
throw new BuildxPluginException($"Option '{optionName}' requires a value.");
}
return args[i + 1];
}
if (argument.StartsWith(optionName + "=", StringComparison.OrdinalIgnoreCase))
{
return argument[(optionName.Length + 1)..];
}
}
return null;
}
private static bool GetFlag(string[] args, string optionName)
{
foreach (var argument in args)
{
if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string RequireOption(string[] args, string optionName)
{
var value = GetOption(args, optionName);
if (string.IsNullOrWhiteSpace(value))
{
throw new BuildxPluginException($"Option '{optionName}' is required.");
}
return value;
}
private static HttpClient CreateAttestorHttpClient(Uri attestorUri, string? bearerToken, bool insecure)
{
var handler = new HttpClientHandler
{
CheckCertificateRevocationList = true,
};
if (insecure && string.Equals(attestorUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
#pragma warning disable S4830 // Explicitly gated by --attestor-insecure flag/env for dev/test usage.
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
#pragma warning restore S4830
}
var client = new HttpClient(handler, disposeHandler: true)
{
Timeout = TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (!string.IsNullOrWhiteSpace(bearerToken))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
}
return client;
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<AssemblyName>StellaOps.Scanner.Sbomer.BuildXPlugin</AssemblyName>
<RootNamespace>StellaOps.Scanner.Sbomer.BuildXPlugin</RootNamespace>
<Version>0.1.0-alpha</Version>
<FileVersion>0.1.0.0</FileVersion>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
<InformationalVersion>0.1.0-alpha</InformationalVersion>
</PropertyGroup>
<ItemGroup>
<Content Include="stellaops.sbom-indexer.manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
# BuildX Plugin Task Board (Sprint 9)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SP9-BLDX-09-001 | DONE | BuildX Guild | SCANNER-EMIT-10-601 (awareness) | Scaffold buildx driver, manifest, local CAS handshake; ensure plugin loads from `plugins/scanner/buildx/`. | Plugin manifest + loader tests; local CAS writes succeed; restart required to activate. |
| SP9-BLDX-09-002 | DONE | BuildX Guild | SP9-BLDX-09-001 | Emit OCI annotations + provenance metadata for Attestor handoff (image + SBOM). | OCI descriptors include DSSE/provenance placeholders; Attestor mock accepts payload. |
| SP9-BLDX-09-003 | DONE | BuildX Guild | SP9-BLDX-09-002 | CI demo pipeline: build sample image, produce SBOM, verify backend report wiring. | GitHub/CI job runs sample build within 5s overhead; artifacts saved; documentation updated. |
| SP9-BLDX-09-004 | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-002 | Stabilize descriptor nonce derivation so repeated builds emit deterministic placeholders. | Repeated descriptor runs with fixed inputs yield identical JSON; regression tests cover nonce determinism. |
| SP9-BLDX-09-005 | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-004 | Integrate determinism check in GitHub/Gitea workflows and capture sample artifacts. | Determinism step runs in `.gitea/workflows/build-test-deploy.yml` and `samples/ci/buildx-demo`, producing matching descriptors + archived artifacts. |

View File

@@ -0,0 +1,35 @@
{
"schemaVersion": "1.0",
"id": "stellaops.sbom-indexer",
"displayName": "StellaOps SBOM BuildX Generator",
"version": "0.1.0-alpha",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"executable": "StellaOps.Scanner.Sbomer.BuildXPlugin.dll",
"arguments": [
"handshake"
]
},
"capabilities": [
"generator",
"sbom"
],
"cas": {
"protocol": "filesystem",
"defaultRoot": "cas",
"compression": "zstd"
},
"image": {
"name": "stellaops/sbom-indexer",
"digest": null,
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"metadata": {
"org.stellaops.plugin.kind": "buildx-generator",
"org.stellaops.restart.required": "true"
}
}

View File

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

View File

@@ -0,0 +1,10 @@
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 RateLimited = "https://stellaops.org/problems/rate-limit";
}

View File

@@ -0,0 +1,277 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
internal static class OrchestratorEventKinds
{
public const string ScannerReportReady = "scanner.event.report.ready";
public const string ScannerScanCompleted = "scanner.event.scan.completed";
}
internal sealed record OrchestratorEvent
{
[JsonPropertyName("eventId")]
[JsonPropertyOrder(0)]
public Guid EventId { get; init; }
[JsonPropertyName("kind")]
[JsonPropertyOrder(1)]
public string Kind { get; init; } = string.Empty;
[JsonPropertyName("version")]
[JsonPropertyOrder(2)]
public int Version { get; init; } = 1;
[JsonPropertyName("tenant")]
[JsonPropertyOrder(3)]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("occurredAt")]
[JsonPropertyOrder(4)]
public DateTimeOffset OccurredAt { get; init; }
[JsonPropertyName("recordedAt")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? RecordedAt { get; init; }
[JsonPropertyName("source")]
[JsonPropertyOrder(6)]
public string Source { get; init; } = string.Empty;
[JsonPropertyName("idempotencyKey")]
[JsonPropertyOrder(7)]
public string IdempotencyKey { get; init; } = string.Empty;
[JsonPropertyName("correlationId")]
[JsonPropertyOrder(8)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; init; }
[JsonPropertyName("traceId")]
[JsonPropertyOrder(9)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TraceId { get; init; }
[JsonPropertyName("spanId")]
[JsonPropertyOrder(10)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SpanId { get; init; }
[JsonPropertyName("scope")]
[JsonPropertyOrder(11)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public OrchestratorEventScope? Scope { get; init; }
[JsonPropertyName("payload")]
[JsonPropertyOrder(12)]
public OrchestratorEventPayload Payload { get; init; } = default!;
[JsonPropertyName("attributes")]
[JsonPropertyOrder(13)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableSortedDictionary<string, string>? Attributes { get; init; }
}
internal sealed record OrchestratorEventScope
{
[JsonPropertyName("namespace")]
[JsonPropertyOrder(0)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Namespace { get; init; }
[JsonPropertyName("repo")]
[JsonPropertyOrder(1)]
public string Repo { get; init; } = string.Empty;
[JsonPropertyName("digest")]
[JsonPropertyOrder(2)]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("component")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Component { get; init; }
[JsonPropertyName("image")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Image { get; init; }
}
internal abstract record OrchestratorEventPayload;
internal sealed record ReportReadyEventPayload : OrchestratorEventPayload
{
[JsonPropertyName("reportId")]
[JsonPropertyOrder(0)]
public string ReportId { get; init; } = string.Empty;
[JsonPropertyName("scanId")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ScanId { get; init; }
[JsonPropertyName("imageDigest")]
[JsonPropertyOrder(2)]
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
[JsonPropertyOrder(3)]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("verdict")]
[JsonPropertyOrder(4)]
public string Verdict { get; init; } = string.Empty;
[JsonPropertyName("summary")]
[JsonPropertyOrder(5)]
public ReportSummaryDto Summary { get; init; } = new();
[JsonPropertyName("delta")]
[JsonPropertyOrder(6)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ReportDeltaPayload? Delta { get; init; }
[JsonPropertyName("quietedFindingCount")]
[JsonPropertyOrder(7)]
public int QuietedFindingCount { get; init; }
[JsonPropertyName("policy")]
[JsonPropertyOrder(8)]
public ReportPolicyDto Policy { get; init; } = new();
[JsonPropertyName("links")]
[JsonPropertyOrder(9)]
public ReportLinksPayload Links { get; init; } = new();
[JsonPropertyName("dsse")]
[JsonPropertyOrder(10)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DsseEnvelopeDto? Dsse { get; init; }
[JsonPropertyName("report")]
[JsonPropertyOrder(11)]
public ReportDocumentDto Report { get; init; } = new();
}
internal sealed record ScanCompletedEventPayload : OrchestratorEventPayload
{
[JsonPropertyName("reportId")]
[JsonPropertyOrder(0)]
public string ReportId { get; init; } = string.Empty;
[JsonPropertyName("scanId")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ScanId { get; init; }
[JsonPropertyName("imageDigest")]
[JsonPropertyOrder(2)]
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("verdict")]
[JsonPropertyOrder(3)]
public string Verdict { get; init; } = string.Empty;
[JsonPropertyName("summary")]
[JsonPropertyOrder(4)]
public ReportSummaryDto Summary { get; init; } = new();
[JsonPropertyName("delta")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ReportDeltaPayload? Delta { get; init; }
[JsonPropertyName("policy")]
[JsonPropertyOrder(6)]
public ReportPolicyDto Policy { get; init; } = new();
[JsonPropertyName("findings")]
[JsonPropertyOrder(7)]
public IReadOnlyList<FindingSummaryPayload> Findings { get; init; } = Array.Empty<FindingSummaryPayload>();
[JsonPropertyName("links")]
[JsonPropertyOrder(8)]
public ReportLinksPayload Links { get; init; } = new();
[JsonPropertyName("dsse")]
[JsonPropertyOrder(9)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DsseEnvelopeDto? Dsse { get; init; }
[JsonPropertyName("report")]
[JsonPropertyOrder(10)]
public ReportDocumentDto Report { get; init; } = new();
}
internal sealed record ReportDeltaPayload
{
[JsonPropertyName("newCritical")]
[JsonPropertyOrder(0)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? NewCritical { get; init; }
[JsonPropertyName("newHigh")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? NewHigh { get; init; }
[JsonPropertyName("kev")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? Kev { get; init; }
}
internal sealed record ReportLinksPayload
{
[JsonPropertyName("ui")]
[JsonPropertyOrder(0)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Ui { get; init; }
[JsonPropertyName("report")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Report { get; init; }
[JsonPropertyName("policy")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Policy { get; init; }
[JsonPropertyName("attestation")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Attestation { get; init; }
}
internal sealed record FindingSummaryPayload
{
[JsonPropertyName("id")]
[JsonPropertyOrder(0)]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("severity")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Severity { get; init; }
[JsonPropertyName("cve")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Cve { get; init; }
[JsonPropertyName("purl")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Purl { get; init; }
[JsonPropertyName("reachability")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reachability { get; init; }
}

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

@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record PolicyPreviewRequestDto
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("findings")]
public IReadOnlyList<PolicyPreviewFindingDto>? Findings { get; init; }
[JsonPropertyName("baseline")]
public IReadOnlyList<PolicyPreviewVerdictDto>? Baseline { get; init; }
[JsonPropertyName("policy")]
public PolicyPreviewPolicyDto? Policy { get; init; }
}
public sealed record PolicyPreviewFindingDto
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("environment")]
public string? Environment { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("vendor")]
public string? Vendor { get; init; }
[JsonPropertyName("license")]
public string? License { get; init; }
[JsonPropertyName("image")]
public string? Image { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
[JsonPropertyName("package")]
public string? Package { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("cve")]
public string? Cve { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
[JsonPropertyName("layerDigest")]
public string? LayerDigest { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
public sealed record PolicyPreviewVerdictDto
{
[JsonPropertyName("findingId")]
public string? FindingId { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("ruleName")]
public string? RuleName { get; init; }
[JsonPropertyName("ruleAction")]
public string? RuleAction { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
[JsonPropertyName("score")]
public double? Score { get; init; }
[JsonPropertyName("configVersion")]
public string? ConfigVersion { get; init; }
[JsonPropertyName("inputs")]
public IReadOnlyDictionary<string, double>? Inputs { get; init; }
[JsonPropertyName("quietedBy")]
public string? QuietedBy { get; init; }
[JsonPropertyName("quiet")]
public bool? Quiet { get; init; }
[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
{
[JsonPropertyName("content")]
public string? Content { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("actor")]
public string? Actor { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed record PolicyPreviewResponseDto
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("policyDigest")]
public string? PolicyDigest { get; init; }
[JsonPropertyName("revisionId")]
public string? RevisionId { get; init; }
[JsonPropertyName("changed")]
public int Changed { get; init; }
[JsonPropertyName("diffs")]
public IReadOnlyList<PolicyPreviewDiffDto> Diffs { get; init; } = Array.Empty<PolicyPreviewDiffDto>();
[JsonPropertyName("issues")]
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
}
public sealed record PolicyPreviewDiffDto
{
[JsonPropertyName("findingId")]
public string? FindingId { get; init; }
[JsonPropertyName("baseline")]
public PolicyPreviewVerdictDto? Baseline { get; init; }
[JsonPropertyName("projected")]
public PolicyPreviewVerdictDto? Projected { get; init; }
[JsonPropertyName("changed")]
public bool Changed { get; init; }
}
public sealed record PolicyPreviewIssueDto
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("severity")]
public string Severity { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
}

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,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,91 @@
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("confidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Confidence { get; init; }
[JsonPropertyName("quieted")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Quieted { get; init; }
[JsonPropertyName("quietedBy")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? QuietedBy { get; init; }
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Metadata { get; init; }
[JsonPropertyName("buildIds")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? BuildIds { 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

@@ -0,0 +1,13 @@
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ScanStatusResponse(
string ScanId,
string Status,
ScanStatusTarget Image,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
string? FailureReason);
public sealed record ScanStatusTarget(
string? Reference,
string? Digest);

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ScanSubmitRequest
{
public required ScanImageDescriptor Image { get; init; } = new();
public bool Force { get; init; }
public string? ClientRequestId { get; init; }
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed record ScanImageDescriptor
{
public string? Reference { get; init; }
public string? Digest { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record ScanSubmitResponse(
string ScanId,
string Status,
string? Location,
bool Created);

View File

@@ -0,0 +1,47 @@
using System;
namespace StellaOps.Scanner.WebService.Diagnostics;
/// <summary>
/// Tracks runtime health snapshots for the Scanner WebService.
/// </summary>
public sealed class ServiceStatus
{
private readonly TimeProvider timeProvider;
private readonly DateTimeOffset startedAt;
private ReadySnapshot readySnapshot;
public ServiceStatus(TimeProvider timeProvider)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
startedAt = timeProvider.GetUtcNow();
readySnapshot = ReadySnapshot.CreateInitial(startedAt);
}
public ServiceSnapshot CreateSnapshot()
{
var now = timeProvider.GetUtcNow();
return new ServiceSnapshot(startedAt, now, readySnapshot);
}
public void RecordReadyCheck(bool success, TimeSpan latency, string? error)
{
var now = timeProvider.GetUtcNow();
readySnapshot = new ReadySnapshot(now, latency, success, success ? null : error);
}
public readonly record struct ServiceSnapshot(
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
ReadySnapshot Ready);
public readonly record struct ReadySnapshot(
DateTimeOffset CheckedAt,
TimeSpan? Latency,
bool IsReady,
string? Error)
{
public static ReadySnapshot CreateInitial(DateTimeOffset timestamp)
=> new ReadySnapshot(timestamp, null, true, null);
}
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Scanner.WebService.Domain;
public readonly record struct ScanId(string Value)
{
public override string ToString() => Value;
public static bool TryParse(string? value, out ScanId scanId)
{
if (!string.IsNullOrWhiteSpace(value))
{
scanId = new ScanId(value.Trim());
return true;
}
scanId = default;
return false;
}
}

View File

@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanProgressEvent(
ScanId ScanId,
int Sequence,
DateTimeOffset Timestamp,
string State,
string? Message,
string CorrelationId,
IReadOnlyDictionary<string, object?> Data);

View File

@@ -0,0 +1,9 @@
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanSnapshot(
ScanId ScanId,
ScanTarget Target,
ScanStatus Status,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
string? FailureReason);

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Scanner.WebService.Domain;
public enum ScanStatus
{
Pending,
Running,
Succeeded,
Failed,
Cancelled
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanSubmission(
ScanTarget Target,
bool Force,
string? ClientRequestId,
IReadOnlyDictionary<string, string> Metadata);
public sealed record ScanSubmissionResult(
ScanSnapshot Snapshot,
bool Created);

View File

@@ -0,0 +1,11 @@
namespace StellaOps.Scanner.WebService.Domain;
public sealed record ScanTarget(string? Reference, string? Digest)
{
public ScanTarget Normalize()
{
var normalizedReference = string.IsNullOrWhiteSpace(Reference) ? null : Reference.Trim();
var normalizedDigest = string.IsNullOrWhiteSpace(Digest) ? null : Digest.Trim().ToLowerInvariant();
return new ScanTarget(normalizedReference, normalizedDigest);
}
}

View File

@@ -0,0 +1,112 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Diagnostics;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class HealthEndpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static void MapHealthEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var group = endpoints.MapGroup("/");
group.MapGet("/healthz", HandleHealth)
.WithName("scanner.health")
.Produces<HealthDocument>(StatusCodes.Status200OK)
.AllowAnonymous();
group.MapGet("/readyz", HandleReady)
.WithName("scanner.ready")
.Produces<ReadyDocument>(StatusCodes.Status200OK)
.AllowAnonymous();
}
private static IResult HandleHealth(
ServiceStatus status,
IOptions<ScannerWebServiceOptions> options,
HttpContext context)
{
ApplyNoCache(context.Response);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var telemetry = new TelemetrySnapshot(
Enabled: options.Value.Telemetry.Enabled,
Logging: options.Value.Telemetry.EnableLogging,
Metrics: options.Value.Telemetry.EnableMetrics,
Tracing: options.Value.Telemetry.EnableTracing);
var document = new HealthDocument(
Status: "healthy",
StartedAt: snapshot.StartedAt,
CapturedAt: snapshot.CapturedAt,
UptimeSeconds: uptimeSeconds,
Telemetry: telemetry);
return Json(document, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleReady(
ServiceStatus status,
HttpContext context,
CancellationToken cancellationToken)
{
ApplyNoCache(context.Response);
await Task.CompletedTask;
status.RecordReadyCheck(success: true, latency: TimeSpan.Zero, error: null);
var snapshot = status.CreateSnapshot();
var ready = snapshot.Ready;
var document = new ReadyDocument(
Status: ready.IsReady ? "ready" : "unready",
CheckedAt: ready.CheckedAt,
LatencyMs: ready.Latency?.TotalMilliseconds,
Error: ready.Error);
return Json(document, StatusCodes.Status200OK);
}
private static void ApplyNoCache(HttpResponse response)
{
response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
response.Headers.Pragma = "no-cache";
response.Headers["Expires"] = "0";
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, JsonOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
internal sealed record TelemetrySnapshot(
bool Enabled,
bool Logging,
bool Metrics,
bool Tracing);
internal sealed record HealthDocument(
string Status,
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
double UptimeSeconds,
TelemetrySnapshot Telemetry);
internal sealed record ReadyDocument(
string Status,
DateTimeOffset CheckedAt,
double? LatencyMs,
string? Error);
}

View File

@@ -0,0 +1,337 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Zastava.Core.Contracts;
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class PolicyEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapPolicyEndpoints(this RouteGroupBuilder apiGroup, string policySegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var policyGroup = apiGroup
.MapGroup(NormalizeSegment(policySegment))
.WithTags("Policy");
policyGroup.MapGet("/schema", HandleSchemaAsync)
.WithName("scanner.policy.schema")
.Produces(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Retrieve the embedded policy JSON schema.";
operation.Description = "Returns the policy schema (`policy-schema@1`) used to validate YAML or JSON rulesets.";
return operation;
});
policyGroup.MapPost("/diagnostics", HandleDiagnosticsAsync)
.WithName("scanner.policy.diagnostics")
.Produces<PolicyDiagnosticsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Run policy diagnostics.";
operation.Description = "Accepts YAML or JSON policy content and returns normalization issues plus recommendations (ignore rules, VEX include/exclude, vendor precedence).";
return operation;
});
policyGroup.MapPost("/preview", HandlePreviewAsync)
.WithName("scanner.policy.preview")
.Produces<PolicyPreviewResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Preview policy impact against findings.";
operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages.";
return operation;
});
policyGroup.MapPost("/runtime", HandleRuntimePolicyAsync)
.WithName("scanner.policy.runtime")
.Produces<RuntimePolicyResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Evaluate runtime policy for digests.";
operation.Description = "Returns per-image policy verdicts, signature and SBOM metadata, and cache hints for admission controllers.";
return operation;
});
}
private static IResult HandleSchemaAsync(HttpContext context)
{
var schema = PolicySchemaResource.ReadSchemaJson();
return Results.Text(schema, "application/schema+json", Encoding.UTF8);
}
private static IResult HandleDiagnosticsAsync(
PolicyDiagnosticsRequestDto request,
TimeProvider timeProvider,
HttpContext context)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(timeProvider);
if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Content))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy diagnostics request",
StatusCodes.Status400BadRequest,
detail: "Policy content is required for diagnostics.");
}
var format = PolicyDtoMapper.ParsePolicyFormat(request.Policy.Format);
var binding = PolicyBinder.Bind(request.Policy.Content, format);
var diagnostics = PolicyDiagnostics.Create(binding, timeProvider);
var response = new PolicyDiagnosticsResponseDto
{
Success = diagnostics.ErrorCount == 0,
Version = diagnostics.Version,
RuleCount = diagnostics.RuleCount,
ErrorCount = diagnostics.ErrorCount,
WarningCount = diagnostics.WarningCount,
GeneratedAt = diagnostics.GeneratedAt,
Issues = diagnostics.Issues.Select(PolicyDtoMapper.ToIssueDto).ToImmutableArray(),
Recommendations = diagnostics.Recommendations
};
return Json(response);
}
private static async Task<IResult> HandlePreviewAsync(
PolicyPreviewRequestDto request,
PolicyPreviewService previewService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(previewService);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null)
{
var missingIds = request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id));
if (missingIds)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
}
var domainRequest = PolicyDtoMapper.ToDomain(request);
var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var payload = PolicyDtoMapper.ToDto(response);
return Json(payload);
}
private static async Task<IResult> HandleRuntimePolicyAsync(
RuntimePolicyRequestDto request,
IRuntimePolicyService runtimePolicyService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(runtimePolicyService);
if (request.Images is null || request.Images.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "images collection must include at least one digest.");
}
var normalizedImages = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var image in request.Images)
{
if (string.IsNullOrWhiteSpace(image))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "Image digests must be non-empty.");
}
var trimmed = image.Trim();
if (!trimmed.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "Image digests must include an algorithm prefix (e.g. sha256:...).");
}
if (seen.Add(trimmed))
{
normalizedImages.Add(trimmed);
}
}
if (normalizedImages.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime policy request",
StatusCodes.Status400BadRequest,
detail: "images collection must include at least one unique digest.");
}
var namespaceValue = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim();
var normalizedLabels = new Dictionary<string, string>(StringComparer.Ordinal);
if (request.Labels is not null)
{
foreach (var pair in request.Labels)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
var key = pair.Key.Trim();
var value = pair.Value?.Trim() ?? string.Empty;
normalizedLabels[key] = value;
}
}
var evaluationRequest = new RuntimePolicyEvaluationRequest(
namespaceValue,
new ReadOnlyDictionary<string, string>(normalizedLabels),
normalizedImages);
var evaluation = await runtimePolicyService.EvaluateAsync(evaluationRequest, cancellationToken).ConfigureAwait(false);
var resultPayload = MapRuntimePolicyResponse(evaluation);
return Json(resultPayload);
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/policy";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
private static RuntimePolicyResponseDto MapRuntimePolicyResponse(RuntimePolicyEvaluationResult evaluation)
{
var results = new Dictionary<string, RuntimePolicyImageResponseDto>(evaluation.Results.Count, StringComparer.Ordinal);
foreach (var pair in evaluation.Results)
{
var decision = pair.Value;
RuntimePolicyRekorDto? rekor = null;
if (decision.Rekor is not null)
{
rekor = new RuntimePolicyRekorDto
{
Uuid = decision.Rekor.Uuid,
Url = decision.Rekor.Url,
Verified = decision.Rekor.Verified
};
}
string? metadata = null;
if (decision.Metadata is not null && decision.Metadata.Count > 0)
{
metadata = JsonSerializer.Serialize(decision.Metadata, SerializerOptions);
}
results[pair.Key] = new RuntimePolicyImageResponseDto
{
PolicyVerdict = ToCamelCase(decision.PolicyVerdict),
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
HasSbomLegacy = decision.HasSbomReferrers,
Reasons = decision.Reasons.ToArray(),
Rekor = rekor,
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
Quieted = decision.Quieted,
QuietedBy = decision.QuietedBy,
Metadata = metadata,
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null
};
}
return new RuntimePolicyResponseDto
{
TtlSeconds = evaluation.TtlSeconds,
ExpiresAtUtc = evaluation.ExpiresAtUtc,
PolicyRevision = evaluation.PolicyRevision,
Results = results
};
}
private static string ToCamelCase(RuntimePolicyVerdict verdict)
=> verdict switch
{
RuntimePolicyVerdict.Pass => "pass",
RuntimePolicyVerdict.Warn => "warn",
RuntimePolicyVerdict.Fail => "fail",
RuntimePolicyVerdict.Error => "error",
_ => "unknown"
};
}

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

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

@@ -0,0 +1,309 @@
using System.Collections.Generic;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class ScanEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
public static void MapScanEndpoints(this RouteGroupBuilder apiGroup, string scansSegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var scans = apiGroup.MapGroup(NormalizeSegment(scansSegment));
scans.MapPost("/", HandleSubmitAsync)
.WithName("scanner.scans.submit")
.Produces<ScanSubmitResponse>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(ScannerPolicies.ScansEnqueue);
scans.MapGet("/{scanId}", HandleStatusAsync)
.WithName("scanner.scans.status")
.Produces<ScanStatusResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
scans.MapGet("/{scanId}/events", HandleProgressStreamAsync)
.WithName("scanner.scans.events")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleSubmitAsync(
ScanSubmitRequest request,
IScanCoordinator coordinator,
LinkGenerator links,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(links);
if (request.Image is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan submission",
StatusCodes.Status400BadRequest,
detail: "Request image descriptor is required.");
}
var reference = request.Image.Reference;
var digest = request.Image.Digest;
if (string.IsNullOrWhiteSpace(reference) && string.IsNullOrWhiteSpace(digest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan submission",
StatusCodes.Status400BadRequest,
detail: "Either image.reference or image.digest must be provided.");
}
if (!string.IsNullOrWhiteSpace(digest) && !digest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan submission",
StatusCodes.Status400BadRequest,
detail: "Image digest must include algorithm prefix (e.g. sha256:...).");
}
var target = new ScanTarget(reference, digest).Normalize();
var metadata = NormalizeMetadata(request.Metadata);
var submission = new ScanSubmission(
Target: target,
Force: request.Force,
ClientRequestId: request.ClientRequestId?.Trim(),
Metadata: metadata);
ScanSubmissionResult result;
try
{
result = await coordinator.SubmitAsync(submission, context.RequestAborted).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
var statusText = result.Snapshot.Status.ToString();
var location = links.GetPathByName(
httpContext: context,
endpointName: "scanner.scans.status",
values: new { scanId = result.Snapshot.ScanId.Value });
if (!string.IsNullOrWhiteSpace(location))
{
context.Response.Headers.Location = location;
}
var response = new ScanSubmitResponse(
ScanId: result.Snapshot.ScanId.Value,
Status: statusText,
Location: location,
Created: result.Created);
return Json(response, StatusCodes.Status202Accepted);
}
private static async Task<IResult> HandleStatusAsync(
string scanId,
IScanCoordinator coordinator,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
var snapshot = await coordinator.GetAsync(parsed, context.RequestAborted).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
var response = new ScanStatusResponse(
ScanId: snapshot.ScanId.Value,
Status: snapshot.Status.ToString(),
Image: new ScanStatusTarget(snapshot.Target.Reference, snapshot.Target.Digest),
CreatedAt: snapshot.CreatedAt,
UpdatedAt: snapshot.UpdatedAt,
FailureReason: snapshot.FailureReason);
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleProgressStreamAsync(
string scanId,
string? format,
IScanProgressReader progressReader,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(progressReader);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
if (!progressReader.Exists(parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
var streamFormat = string.Equals(format, "jsonl", StringComparison.OrdinalIgnoreCase)
? "jsonl"
: "sse";
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-Accel-Buffering"] = "no";
context.Response.Headers["Connection"] = "keep-alive";
if (streamFormat == "jsonl")
{
context.Response.ContentType = "application/x-ndjson";
}
else
{
context.Response.ContentType = "text/event-stream";
}
await foreach (var progressEvent in progressReader.SubscribeAsync(parsed, context.RequestAborted).WithCancellation(context.RequestAborted))
{
var payload = new
{
scanId = progressEvent.ScanId.Value,
sequence = progressEvent.Sequence,
state = progressEvent.State,
message = progressEvent.Message,
timestamp = progressEvent.Timestamp,
correlationId = progressEvent.CorrelationId,
data = progressEvent.Data
};
if (streamFormat == "jsonl")
{
await WriteJsonLineAsync(context.Response.BodyWriter, payload, cancellationToken).ConfigureAwait(false);
}
else
{
await WriteSseAsync(context.Response.BodyWriter, payload, progressEvent, cancellationToken).ConfigureAwait(false);
}
await context.Response.BodyWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
}
return Results.Empty;
}
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string> metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new Dictionary<string, string>();
}
var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in metadata)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
var key = pair.Key.Trim();
var value = pair.Value?.Trim() ?? string.Empty;
normalized[key] = value;
}
return normalized;
}
private static async Task WriteJsonLineAsync(PipeWriter writer, object payload, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(payload, SerializerOptions);
var jsonBytes = Encoding.UTF8.GetBytes(json);
await writer.WriteAsync(jsonBytes, cancellationToken).ConfigureAwait(false);
await writer.WriteAsync(new[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
}
private static async Task WriteSseAsync(PipeWriter writer, object payload, ScanProgressEvent progressEvent, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(payload, SerializerOptions);
var eventName = progressEvent.State.ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("id: ").Append(progressEvent.Sequence).Append('\n');
builder.Append("event: ").Append(eventName).Append('\n');
builder.Append("data: ").Append(json).Append('\n');
builder.Append('\n');
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
await writer.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/scans";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
}

View File

@@ -0,0 +1,38 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Scanner.WebService.Extensions;
/// <summary>
/// Scanner-specific configuration helpers.
/// </summary>
public static class ConfigurationExtensions
{
public static IConfigurationBuilder AddScannerYaml(this IConfigurationBuilder builder, string path)
{
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var payload = JsonSerializer.Serialize(yamlObject);
var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
return builder.AddJsonStream(stream);
}
}

View File

@@ -0,0 +1,92 @@
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));
if (method is not null)
{
try
{
var parameters = method.GetParameters();
object?[] arguments = parameters.Length switch
{
2 => new object?[] { services, null },
3 => new object?[] { services, "scanner", null },
_ => Array.Empty<object?>()
};
if (arguments.Length == parameters.Length)
{
var result = method.Invoke(null, arguments);
if (result is IServiceCollection collection)
{
return collection;
}
}
}
catch
{
// Fall back to minimal explorer registration below.
}
}
}
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));
if (method is not null)
{
try
{
var parameters = method.GetParameters();
object?[] arguments = parameters.Length switch
{
1 => new object?[] { app },
2 => new object?[] { app, "scanner" },
_ => Array.Empty<object?>()
};
if (arguments.Length == parameters.Length)
{
method.Invoke(null, arguments);
}
}
catch
{
// Ignore failures and continue without OpenAPI mapping.
}
}
}
return app;
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Hosting;
internal static class ScannerPluginHostFactory
{
public static PluginHostOptions Build(ScannerWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
var baseDirectory = options.Plugins.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = Path.Combine(contentRootPath, "..");
}
else if (!Path.IsPathRooted(baseDirectory))
{
baseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, baseDirectory));
}
var pluginsDirectory = options.Plugins.Directory;
if (string.IsNullOrWhiteSpace(pluginsDirectory))
{
pluginsDirectory = Path.Combine("plugins", "scanner");
}
if (!Path.IsPathRooted(pluginsDirectory))
{
pluginsDirectory = Path.Combine(baseDirectory, pluginsDirectory);
}
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
PrimaryPrefix = "StellaOps.Scanner"
};
foreach (var additionalPrefix in options.Plugins.OrderedPlugins)
{
hostOptions.PluginOrder.Add(additionalPrefix);
}
foreach (var pattern in options.Plugins.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
return hostOptions;
}
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Scanner.WebService.Infrastructure;
internal static class ProblemResultFactory
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static IResult Create(
HttpContext context,
string type,
string title,
int statusCode,
string? detail = null,
IDictionary<string, object?>? extensions = null)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(type);
ArgumentException.ThrowIfNullOrWhiteSpace(title);
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
var problem = new ProblemDetails
{
Type = type,
Title = title,
Detail = detail,
Status = statusCode,
Instance = context.Request.Path
};
problem.Extensions["traceId"] = traceId;
if (extensions is not null)
{
foreach (var entry in extensions)
{
problem.Extensions[entry.Key] = entry.Value;
}
}
var payload = JsonSerializer.Serialize(problem, JsonOptions);
return Results.Content(payload, "application/problem+json", Encoding.UTF8, statusCode);
}
}

View File

@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Scanner.WebService.Options;
/// <summary>
/// Strongly typed configuration for the Scanner WebService host.
/// </summary>
public sealed class ScannerWebServiceOptions
{
public const string SectionName = "scanner";
/// <summary>
/// Schema version for configuration consumers to coordinate breaking changes.
/// </summary>
public int SchemaVersion { get; set; } = 1;
/// <summary>
/// Mongo storage configuration used for catalog and job state.
/// </summary>
public StorageOptions Storage { get; set; } = new();
/// <summary>
/// Queue configuration used to enqueue scan jobs.
/// </summary>
public QueueOptions Queue { get; set; } = new();
/// <summary>
/// Object store configuration for SBOM artefacts.
/// </summary>
public ArtifactStoreOptions ArtifactStore { get; set; } = new();
/// <summary>
/// Feature flags toggling optional behaviours.
/// </summary>
public FeatureFlagOptions Features { get; set; } = new();
/// <summary>
/// Plug-in loader configuration.
/// </summary>
public PluginOptions Plugins { get; set; } = new();
/// <summary>
/// Telemetry configuration for logs, metrics, traces.
/// </summary>
public TelemetryOptions Telemetry { get; set; } = new();
/// <summary>
/// Authority / authentication configuration.
/// </summary>
public AuthorityOptions Authority { get; set; } = new();
/// <summary>
/// Signing configuration for report envelopes and attestations.
/// </summary>
public SigningOptions Signing { get; set; } = new();
/// <summary>
/// API-specific settings such as base path.
/// </summary>
public ApiOptions Api { get; set; } = new();
/// <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
{
public string Driver { get; set; } = "mongo";
public string Dsn { get; set; } = string.Empty;
public string? Database { get; set; }
public int CommandTimeoutSeconds { get; set; } = 30;
public int HealthCheckTimeoutSeconds { get; set; } = 5;
public IList<string> Migrations { get; set; } = new List<string>();
}
public sealed class QueueOptions
{
public string Driver { get; set; } = "redis";
public string Dsn { get; set; } = string.Empty;
public string Namespace { get; set; } = "scanner";
public int VisibilityTimeoutSeconds { get; set; } = 300;
public int LeaseHeartbeatSeconds { get; set; } = 30;
public int MaxDeliveryAttempts { get; set; } = 5;
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed class ArtifactStoreOptions
{
public string Driver { get; set; } = "rustfs";
public string Endpoint { get; set; } = string.Empty;
public bool UseTls { get; set; } = true;
public bool AllowInsecureTls { get; set; }
= false;
public int TimeoutSeconds { get; set; } = 60;
public string AccessKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string? SecretKeyFile { get; set; }
public string Bucket { get; set; } = "scanner-artifacts";
public string? Region { get; set; }
public bool EnableObjectLock { get; set; } = true;
public int ObjectLockRetentionDays { get; set; } = 30;
public string? ApiKey { get; set; }
public string ApiKeyHeader { get; set; } = string.Empty;
public IDictionary<string, string> Headers { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed class FeatureFlagOptions
{
public bool AllowAnonymousScanSubmission { get; set; }
public bool EnableSignedReports { get; set; } = true;
public bool EnablePolicyPreview { get; set; } = true;
public IDictionary<string, bool> Experimental { get; set; } = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
}
public sealed class PluginOptions
{
public string? BaseDirectory { get; set; }
public string? Directory { get; set; }
public IList<string> SearchPatterns { get; set; } = new List<string>();
public IList<string> OrderedPlugins { get; set; } = new List<string>();
}
public sealed class TelemetryOptions
{
public bool Enabled { get; set; } = true;
public bool EnableTracing { get; set; } = true;
public bool EnableMetrics { get; set; } = true;
public bool EnableLogging { get; set; } = true;
public bool EnableRequestLogging { get; set; } = true;
public string MinimumLogLevel { get; set; } = "Information";
public string? ServiceName { get; set; }
public string? OtlpEndpoint { get; set; }
public IDictionary<string, string> OtlpHeaders { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> ResourceAttributes { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed class AuthorityOptions
{
public bool Enabled { get; set; }
public bool AllowAnonymousFallback { get; set; } = true;
public string Issuer { get; set; } = string.Empty;
public string? MetadataAddress { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
public IList<string> Audiences { get; set; } = new List<string>();
public IList<string> RequiredScopes { get; set; } = new List<string>();
public IList<string> BypassNetworks { get; set; } = new List<string>();
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? ClientSecretFile { get; set; }
public IList<string> ClientScopes { get; set; } = new List<string>();
public ResilienceOptions Resilience { get; set; } = new();
public sealed class ResilienceOptions
{
public bool? EnableRetries { get; set; }
public IList<TimeSpan> RetryDelays { get; set; } = new List<TimeSpan>();
public bool? AllowOfflineCacheFallback { get; set; }
public TimeSpan? OfflineCacheTolerance { get; set; }
}
}
public sealed class SigningOptions
{
public bool Enabled { get; set; } = false;
public string KeyId { get; set; } = string.Empty;
public string Algorithm { get; set; } = "ed25519";
public string? Provider { get; set; }
public string? KeyPem { get; set; }
public string? KeyPemFile { get; set; }
public string? CertificatePem { get; set; }
public string? CertificatePemFile { get; set; }
public string? CertificateChainPem { get; set; }
public string? CertificateChainPemFile { get; set; }
public int EnvelopeTtlSeconds { get; set; } = 600;
}
public sealed class ApiOptions
{
public string BasePath { get; set; } = "/api/v1";
public string ScansSegment { get; set; } = "scans";
public string ReportsSegment { get; set; } = "reports";
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";
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);
}
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

@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Scanner.WebService.Options;
/// <summary>
/// Post-configuration helpers for <see cref="ScannerWebServiceOptions"/>.
/// </summary>
public static class ScannerWebServiceOptionsPostConfigure
{
public static void Apply(ScannerWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
options.Plugins ??= new ScannerWebServiceOptions.PluginOptions();
if (string.IsNullOrWhiteSpace(options.Plugins.Directory))
{
options.Plugins.Directory = Path.Combine("plugins", "scanner");
}
options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions();
var authority = options.Authority;
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
{
authority.ClientSecret = ReadSecretFile(authority.ClientSecretFile!, contentRootPath);
}
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
var artifactStore = options.ArtifactStore;
if (string.IsNullOrWhiteSpace(artifactStore.SecretKey)
&& !string.IsNullOrWhiteSpace(artifactStore.SecretKeyFile))
{
artifactStore.SecretKey = ReadSecretFile(artifactStore.SecretKeyFile!, contentRootPath);
}
options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
var signing = options.Signing;
if (string.IsNullOrWhiteSpace(signing.KeyPem)
&& !string.IsNullOrWhiteSpace(signing.KeyPemFile))
{
signing.KeyPem = ReadAllText(signing.KeyPemFile!, contentRootPath);
}
if (string.IsNullOrWhiteSpace(signing.CertificatePem)
&& !string.IsNullOrWhiteSpace(signing.CertificatePemFile))
{
signing.CertificatePem = ReadAllText(signing.CertificatePemFile!, contentRootPath);
}
if (string.IsNullOrWhiteSpace(signing.CertificateChainPem)
&& !string.IsNullOrWhiteSpace(signing.CertificateChainPemFile))
{
signing.CertificateChainPem = ReadAllText(signing.CertificateChainPemFile!, contentRootPath);
}
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;
}
options.Runtime ??= new ScannerWebServiceOptions.RuntimeOptions();
}
private static string ReadSecretFile(string path, string contentRootPath)
{
var resolvedPath = ResolvePath(path, contentRootPath);
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"Secret file '{resolvedPath}' was not found.");
}
var secret = File.ReadAllText(resolvedPath).Trim();
if (string.IsNullOrEmpty(secret))
{
throw new InvalidOperationException($"Secret file '{resolvedPath}' is empty.");
}
return secret;
}
private static string ReadAllText(string path, string contentRootPath)
{
var resolvedPath = ResolvePath(path, contentRootPath);
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"File '{resolvedPath}' was not found.");
}
return File.ReadAllText(resolvedPath);
}
private static string ResolvePath(string path, string contentRootPath)
=> Path.IsPathRooted(path)
? path
: Path.GetFullPath(Path.Combine(contentRootPath, path));
}

View File

@@ -0,0 +1,466 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Options;
/// <summary>
/// Validation helpers for <see cref="ScannerWebServiceOptions"/>.
/// </summary>
public static class ScannerWebServiceOptionsValidator
{
private static readonly HashSet<string> SupportedStorageDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"mongo"
};
private static readonly HashSet<string> SupportedQueueDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"redis",
"nats",
"rabbitmq"
};
private static readonly HashSet<string> SupportedArtifactDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"minio",
"s3",
"rustfs"
};
private static readonly HashSet<string> SupportedEventDrivers = new(StringComparer.OrdinalIgnoreCase)
{
"redis"
};
public static void Validate(ScannerWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.SchemaVersion <= 0)
{
throw new InvalidOperationException("Scanner configuration requires a positive schemaVersion.");
}
options.Storage ??= new ScannerWebServiceOptions.StorageOptions();
ValidateStorage(options.Storage);
options.Queue ??= new ScannerWebServiceOptions.QueueOptions();
ValidateQueue(options.Queue);
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
ValidateArtifactStore(options.ArtifactStore);
options.Features ??= new ScannerWebServiceOptions.FeatureFlagOptions();
options.Plugins ??= new ScannerWebServiceOptions.PluginOptions();
options.Telemetry ??= new ScannerWebServiceOptions.TelemetryOptions();
ValidateTelemetry(options.Telemetry);
options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions();
ValidateAuthority(options.Authority);
options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
ValidateSigning(options.Signing);
options.Api ??= new ScannerWebServiceOptions.ApiOptions();
if (string.IsNullOrWhiteSpace(options.Api.BasePath))
{
throw new InvalidOperationException("API basePath must be configured.");
}
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.");
}
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)
{
if (!SupportedStorageDrivers.Contains(storage.Driver))
{
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'. Supported drivers: mongo.");
}
if (string.IsNullOrWhiteSpace(storage.Dsn))
{
throw new InvalidOperationException("Storage DSN must be configured.");
}
if (storage.CommandTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Storage commandTimeoutSeconds must be greater than zero.");
}
if (storage.HealthCheckTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Storage healthCheckTimeoutSeconds must be greater than zero.");
}
}
private static void ValidateQueue(ScannerWebServiceOptions.QueueOptions queue)
{
if (!SupportedQueueDrivers.Contains(queue.Driver))
{
throw new InvalidOperationException($"Unsupported queue driver '{queue.Driver}'. Supported drivers: redis, nats, rabbitmq.");
}
if (string.IsNullOrWhiteSpace(queue.Dsn))
{
throw new InvalidOperationException("Queue DSN must be configured.");
}
if (string.IsNullOrWhiteSpace(queue.Namespace))
{
throw new InvalidOperationException("Queue namespace must be configured.");
}
if (queue.VisibilityTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Queue visibilityTimeoutSeconds must be greater than zero.");
}
if (queue.LeaseHeartbeatSeconds <= 0)
{
throw new InvalidOperationException("Queue leaseHeartbeatSeconds must be greater than zero.");
}
if (queue.MaxDeliveryAttempts <= 0)
{
throw new InvalidOperationException("Queue maxDeliveryAttempts must be greater than zero.");
}
}
private static void ValidateArtifactStore(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore)
{
if (!SupportedArtifactDrivers.Contains(artifactStore.Driver))
{
throw new InvalidOperationException($"Unsupported artifact store driver '{artifactStore.Driver}'. Supported drivers: minio, s3, rustfs.");
}
if (string.Equals(artifactStore.Driver, "rustfs", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(artifactStore.Endpoint))
{
throw new InvalidOperationException("Artifact store endpoint must be configured for RustFS.");
}
if (!Uri.TryCreate(artifactStore.Endpoint, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Artifact store endpoint must be an absolute URI for RustFS.");
}
if (artifactStore.TimeoutSeconds <= 0)
{
throw new InvalidOperationException("Artifact store timeoutSeconds must be greater than zero for RustFS.");
}
if (string.IsNullOrWhiteSpace(artifactStore.Bucket))
{
throw new InvalidOperationException("Artifact store bucket must be configured.");
}
return;
}
if (string.IsNullOrWhiteSpace(artifactStore.Endpoint))
{
throw new InvalidOperationException("Artifact store endpoint must be configured.");
}
if (string.IsNullOrWhiteSpace(artifactStore.Bucket))
{
throw new InvalidOperationException("Artifact store bucket must be configured.");
}
if (artifactStore.EnableObjectLock && artifactStore.ObjectLockRetentionDays <= 0)
{
throw new InvalidOperationException("Artifact store objectLockRetentionDays must be greater than zero when object lock is enabled.");
}
}
private static void 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))
{
throw new InvalidOperationException("Telemetry minimumLogLevel must be configured.");
}
if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogLevel _))
{
throw new InvalidOperationException($"Telemetry minimumLogLevel '{telemetry.MinimumLogLevel}' is invalid.");
}
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint) && !Uri.TryCreate(telemetry.OtlpEndpoint, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Telemetry OTLP endpoint must be an absolute URI when specified.");
}
foreach (var attribute in telemetry.ResourceAttributes)
{
if (string.IsNullOrWhiteSpace(attribute.Key))
{
throw new InvalidOperationException("Telemetry resource attribute keys must be non-empty.");
}
}
foreach (var header in telemetry.OtlpHeaders)
{
if (string.IsNullOrWhiteSpace(header.Key))
{
throw new InvalidOperationException("Telemetry OTLP header keys must be non-empty.");
}
}
}
private static void ValidateAuthority(ScannerWebServiceOptions.AuthorityOptions authority)
{
authority.Resilience ??= new ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions();
NormalizeList(authority.Audiences, toLower: false);
NormalizeList(authority.RequiredScopes, toLower: true);
NormalizeList(authority.BypassNetworks, toLower: false);
NormalizeList(authority.ClientScopes, toLower: true);
NormalizeResilience(authority.Resilience);
if (authority.RequiredScopes.Count == 0)
{
authority.RequiredScopes.Add(ScannerAuthorityScopes.ScansEnqueue);
}
if (authority.ClientScopes.Count == 0)
{
foreach (var scope in authority.RequiredScopes)
{
authority.ClientScopes.Add(scope);
}
}
if (authority.BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Authority backchannelTimeoutSeconds must be greater than zero.");
}
if (authority.TokenClockSkewSeconds < 0 || authority.TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Authority tokenClockSkewSeconds must be between 0 and 300 seconds.");
}
if (!authority.Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(authority.Issuer))
{
throw new InvalidOperationException("Authority issuer must be configured when authority is enabled.");
}
if (!Uri.TryCreate(authority.Issuer, UriKind.Absolute, out var issuerUri))
{
throw new InvalidOperationException("Authority issuer must be an absolute URI.");
}
if (authority.RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Authority issuer must use HTTPS when requireHttpsMetadata is enabled.");
}
if (!string.IsNullOrWhiteSpace(authority.MetadataAddress) && !Uri.TryCreate(authority.MetadataAddress, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Authority metadataAddress must be an absolute URI when specified.");
}
if (authority.Audiences.Count == 0)
{
throw new InvalidOperationException("Authority audiences must include at least one entry when authority is enabled.");
}
if (!authority.AllowAnonymousFallback)
{
if (string.IsNullOrWhiteSpace(authority.ClientId))
{
throw new InvalidOperationException("Authority clientId must be configured when anonymous fallback is disabled.");
}
if (string.IsNullOrWhiteSpace(authority.ClientSecret))
{
throw new InvalidOperationException("Authority clientSecret must be configured when anonymous fallback is disabled.");
}
}
}
private static void ValidateSigning(ScannerWebServiceOptions.SigningOptions signing)
{
if (signing.EnvelopeTtlSeconds <= 0)
{
throw new InvalidOperationException("Signing envelopeTtlSeconds must be greater than zero.");
}
if (!signing.Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(signing.KeyId))
{
throw new InvalidOperationException("Signing keyId must be configured when signing is enabled.");
}
if (string.IsNullOrWhiteSpace(signing.Algorithm))
{
throw new InvalidOperationException("Signing algorithm must be configured when signing is enabled.");
}
if (string.IsNullOrWhiteSpace(signing.KeyPem) && string.IsNullOrWhiteSpace(signing.KeyPemFile))
{
throw new InvalidOperationException("Signing requires keyPem or keyPemFile when enabled.");
}
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values is null || values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = values.Count - 1; i >= 0; i--)
{
var entry = values[i];
if (string.IsNullOrWhiteSpace(entry))
{
values.RemoveAt(i);
continue;
}
var normalized = toLower ? entry.Trim().ToLowerInvariant() : entry.Trim();
if (!seen.Add(normalized))
{
values.RemoveAt(i);
continue;
}
values[i] = normalized;
}
}
private static void NormalizeResilience(ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions resilience)
{
if (resilience.RetryDelays is null)
{
return;
}
foreach (var delay in resilience.RetryDelays.ToArray())
{
if (delay <= TimeSpan.Zero)
{
throw new InvalidOperationException("Authority resilience retryDelays must be greater than zero.");
}
}
if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value < TimeSpan.Zero)
{
throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero.");
}
}
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

@@ -0,0 +1,356 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Serilog;
using Serilog.Events;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.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);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "SCANNER_";
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddScannerYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/scanner.yaml"));
};
});
var contentRoot = builder.Environment.ContentRootPath;
var bootstrapOptions = builder.Configuration.BindOptions<ScannerWebServiceOptions>(
ScannerWebServiceOptions.SectionName,
(opts, _) =>
{
ScannerWebServiceOptionsPostConfigure.Apply(opts, contentRoot);
ScannerWebServiceOptionsValidator.Validate(opts);
});
builder.Services.AddOptions<ScannerWebServiceOptions>()
.Bind(builder.Configuration.GetSection(ScannerWebServiceOptions.SectionName))
.PostConfigure(options =>
{
ScannerWebServiceOptionsPostConfigure.Apply(options, contentRoot);
ScannerWebServiceOptionsValidator.Validate(options);
})
.ValidateOnStart();
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
loggerConfiguration
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.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>();
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;
storageOptions.ObjectStore.Headers.Clear();
foreach (var header in bootstrapOptions.ArtifactStore.Headers)
{
storageOptions.ObjectStore.Headers[header.Key] = header.Value;
}
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Bucket))
{
storageOptions.ObjectStore.BucketName = bootstrapOptions.ArtifactStore.Bucket;
}
var artifactDriver = bootstrapOptions.ArtifactStore.Driver?.Trim() ?? string.Empty;
if (string.Equals(artifactDriver, ScannerStorageDefaults.ObjectStoreProviders.RustFs, StringComparison.OrdinalIgnoreCase))
{
storageOptions.ObjectStore.Driver = ScannerStorageDefaults.ObjectStoreProviders.RustFs;
storageOptions.ObjectStore.RustFs.BaseUrl = bootstrapOptions.ArtifactStore.Endpoint;
storageOptions.ObjectStore.RustFs.AllowInsecureTls = bootstrapOptions.ArtifactStore.AllowInsecureTls;
storageOptions.ObjectStore.RustFs.Timeout = TimeSpan.FromSeconds(Math.Max(1, bootstrapOptions.ArtifactStore.TimeoutSeconds));
storageOptions.ObjectStore.RustFs.ApiKey = bootstrapOptions.ArtifactStore.ApiKey;
storageOptions.ObjectStore.RustFs.ApiKeyHeader = bootstrapOptions.ArtifactStore.ApiKeyHeader ?? string.Empty;
storageOptions.ObjectStore.EnableObjectLock = false;
storageOptions.ObjectStore.ComplianceRetention = null;
}
else
{
var resolvedDriver = string.Equals(artifactDriver, ScannerStorageDefaults.ObjectStoreProviders.Minio, StringComparison.OrdinalIgnoreCase)
? ScannerStorageDefaults.ObjectStoreProviders.Minio
: ScannerStorageDefaults.ObjectStoreProviders.S3;
storageOptions.ObjectStore.Driver = resolvedDriver;
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Endpoint))
{
storageOptions.ObjectStore.ServiceUrl = bootstrapOptions.ArtifactStore.Endpoint;
}
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;
storageOptions.ObjectStore.RustFs.ApiKey = null;
storageOptions.ObjectStore.RustFs.ApiKeyHeader = string.Empty;
storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty;
}
});
builder.Services.AddSingleton<RuntimeEventRateLimiter>();
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
builder.Services.AddSingleton<IRuntimeAttestationVerifier, RuntimeAttestationVerifier>();
builder.Services.AddSingleton<IRuntimePolicyService, RuntimePolicyService>();
var pluginHostOptions = ScannerPluginHostFactory.Build(bootstrapOptions, contentRoot);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
builder.Services.AddOpenApiIfAvailable();
if (bootstrapOptions.Authority.Enabled)
{
builder.Services.AddStellaOpsAuthClient(clientOptions =>
{
clientOptions.Authority = bootstrapOptions.Authority.Issuer;
clientOptions.ClientId = bootstrapOptions.Authority.ClientId ?? string.Empty;
clientOptions.ClientSecret = bootstrapOptions.Authority.ClientSecret;
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrapOptions.Authority.BackchannelTimeoutSeconds);
clientOptions.DefaultScopes.Clear();
foreach (var scope in bootstrapOptions.Authority.ClientScopes)
{
clientOptions.DefaultScopes.Add(scope);
}
var resilience = bootstrapOptions.Authority.Resilience ?? new ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions();
if (resilience.EnableRetries.HasValue)
{
clientOptions.EnableRetries = resilience.EnableRetries.Value;
}
if (resilience.RetryDelays is { Count: > 0 })
{
clientOptions.RetryDelays.Clear();
foreach (var delay in resilience.RetryDelays)
{
clientOptions.RetryDelays.Add(delay);
}
}
if (resilience.AllowOfflineCacheFallback.HasValue)
{
clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value;
}
if (resilience.OfflineCacheTolerance.HasValue)
{
clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value;
}
});
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = bootstrapOptions.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(bootstrapOptions.Authority.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(bootstrapOptions.Authority.TokenClockSkewSeconds);
resourceOptions.Audiences.Clear();
foreach (var audience in bootstrapOptions.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
resourceOptions.RequiredScopes.Clear();
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
resourceOptions.BypassNetworks.Clear();
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansEnqueue, bootstrapOptions.Authority.RequiredScopes.ToArray());
options.AddStellaOpsScopePolicy(ScannerPolicies.ScansRead, ScannerAuthorityScopes.ScansRead);
options.AddStellaOpsScopePolicy(ScannerPolicies.Reports, ScannerAuthorityScopes.ReportsRead);
options.AddStellaOpsScopePolicy(ScannerPolicies.RuntimeIngest, ScannerAuthorityScopes.RuntimeIngest);
});
}
else
{
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Anonymous";
options.DefaultChallengeScheme = "Anonymous";
})
.AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", _ => { });
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(ScannerPolicies.ScansEnqueue, policy => policy.RequireAssertion(_ => true));
options.AddPolicy(ScannerPolicies.ScansRead, policy => policy.RequireAssertion(_ => true));
options.AddPolicy(ScannerPolicies.Reports, policy => policy.RequireAssertion(_ => true));
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.");
}
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)
{
app.UseSerilogRequestLogging(options =>
{
options.GetLevel = (httpContext, elapsed, exception) =>
exception is null ? LogEventLevel.Information : LogEventLevel.Error;
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestId", httpContext.TraceIdentifier);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
if (Activity.Current is { TraceId: var traceId } && traceId != default)
{
diagnosticContext.Set("TraceId", traceId.ToString());
}
};
});
}
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.ContentType = "application/problem+json";
var feature = context.Features.Get<IExceptionHandlerFeature>();
var error = feature?.Error;
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
};
var problem = Results.Problem(
detail: error?.Message,
instance: context.Request.Path,
statusCode: StatusCodes.Status500InternalServerError,
title: "Unexpected server error",
type: "https://stellaops.org/problems/internal-error",
extensions: extensions);
await problem.ExecuteAsync(context).ConfigureAwait(false);
});
});
if (authorityConfigured)
{
app.UseAuthentication();
app.UseAuthorization();
}
app.MapHealthEndpoints();
var apiGroup = app.MapGroup(resolvedOptions.Api.BasePath);
if (app.Environment.IsEnvironment("Testing"))
{
apiGroup.MapGet("/__auth-probe", () => Results.Ok("ok"))
.RequireAuthorization(ScannerPolicies.ScansEnqueue)
.WithName("scanner.auth-probe");
}
apiGroup.MapScanEndpoints(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

@@ -0,0 +1,26 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.WebService.Security;
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AnonymousAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(authenticationType: Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Scanner.WebService.Security;
/// <summary>
/// Canonical scope names consumed by the Scanner WebService.
/// </summary>
internal static class ScannerAuthorityScopes
{
public const string ScansEnqueue = "scanner.scans.enqueue";
public const string ScansRead = "scanner.scans.read";
public const string ReportsRead = "scanner.reports.read";
public const string RuntimeIngest = "scanner.runtime.ingest";
}

View File

@@ -0,0 +1,9 @@
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 RuntimeIngest = "scanner.runtime.ingest";
}

View File

@@ -0,0 +1,198 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Serialization;
internal static class OrchestratorEventSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
public static string Serialize(OrchestratorEvent @event)
=> JsonSerializer.Serialize(@event, CompactOptions);
public static string SerializeIndented(OrchestratorEvent @event)
=> JsonSerializer.Serialize(@event, PrettyOptions);
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = writeIndented,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
return options;
}
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
{
private static readonly ImmutableDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
{
[typeof(OrchestratorEvent)] = new[]
{
"eventId",
"kind",
"version",
"tenant",
"occurredAt",
"recordedAt",
"source",
"idempotencyKey",
"correlationId",
"traceId",
"spanId",
"scope",
"payload",
"attributes"
},
[typeof(OrchestratorEventScope)] = new[]
{
"namespace",
"repo",
"digest",
"component",
"image"
},
[typeof(ReportReadyEventPayload)] = new[]
{
"reportId",
"scanId",
"imageDigest",
"generatedAt",
"verdict",
"summary",
"delta",
"quietedFindingCount",
"policy",
"links",
"dsse",
"report"
},
[typeof(ScanCompletedEventPayload)] = new[]
{
"reportId",
"scanId",
"imageDigest",
"verdict",
"summary",
"delta",
"policy",
"findings",
"links",
"dsse",
"report"
},
[typeof(ReportDeltaPayload)] = new[]
{
"newCritical",
"newHigh",
"kev"
},
[typeof(ReportLinksPayload)] = new[]
{
"ui",
"report",
"policy",
"attestation"
},
[typeof(FindingSummaryPayload)] = new[]
{
"id",
"severity",
"cve",
"purl",
"reachability"
},
[typeof(ReportPolicyDto)] = new[]
{
"revisionId",
"digest"
},
[typeof(ReportSummaryDto)] = new[]
{
"total",
"blocked",
"warned",
"ignored",
"quieted"
},
[typeof(ReportDocumentDto)] = new[]
{
"reportId",
"imageDigest",
"generatedAt",
"verdict",
"policy",
"summary",
"verdicts",
"issues"
},
[typeof(DsseEnvelopeDto)] = new[]
{
"payloadType",
"payload",
"signatures"
},
[typeof(DsseSignatureDto)] = new[]
{
"keyId",
"algorithm",
"signature"
}
}.ToImmutableDictionary();
private readonly IJsonTypeInfoResolver _inner;
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options)
?? throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
{
var ordered = info.Properties
.OrderBy(property => GetOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
return info;
}
private static int GetOrder(Type type, string propertyName)
{
if (PropertyOrder.TryGetValue(type, out var order) && Array.IndexOf(order, propertyName) is { } index and >= 0)
{
return index;
}
if (type.BaseType is not null)
{
return GetOrder(type.BaseType, propertyName);
}
return int.MaxValue;
}
}
}

View File

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

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,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,10 @@
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
public interface IScanCoordinator
{
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,80 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Utilities;
namespace StellaOps.Scanner.WebService.Services;
public sealed class InMemoryScanCoordinator : IScanCoordinator
{
private sealed record ScanEntry(ScanSnapshot Snapshot);
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
private readonly IScanProgressPublisher progressPublisher;
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.progressPublisher = progressPublisher ?? throw new ArgumentNullException(nameof(progressPublisher));
}
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(submission);
var normalizedTarget = submission.Target.Normalize();
var metadata = submission.Metadata ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var scanId = ScanIdGenerator.Create(normalizedTarget, submission.Force, submission.ClientRequestId, metadata);
var now = timeProvider.GetUtcNow();
var eventData = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["force"] = submission.Force,
};
foreach (var pair in metadata)
{
eventData[$"meta.{pair.Key}"] = pair.Value;
}
ScanEntry entry = scans.AddOrUpdate(
scanId.Value,
_ => new ScanEntry(new ScanSnapshot(
scanId,
normalizedTarget,
ScanStatus.Pending,
now,
now,
null)),
(_, existing) =>
{
if (submission.Force)
{
var snapshot = existing.Snapshot with
{
Status = ScanStatus.Pending,
UpdatedAt = now,
FailureReason = null
};
return new ScanEntry(snapshot);
}
return existing;
});
var created = entry.Snapshot.CreatedAt == now;
var state = entry.Snapshot.Status.ToString();
progressPublisher.Publish(scanId, state, created ? "queued" : "requeued", eventData);
return ValueTask.FromResult(new ScanSubmissionResult(entry.Snapshot, created));
}
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
{
if (scans.TryGetValue(scanId.Value, out var entry))
{
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
}
return ValueTask.FromResult<ScanSnapshot?>(null);
}
}

View File

@@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.WebService.Contracts;
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(OrchestratorEvent @event, CancellationToken cancellationToken = default)
{
if (@event is null)
{
throw new ArgumentNullException(nameof(@event));
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Suppressing publish for orchestrator 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,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

@@ -0,0 +1,154 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Serialization;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAsyncDisposable
{
private readonly ScannerWebServiceOptions.EventsOptions _options;
private readonly ILogger<RedisPlatformEventPublisher> _logger;
private readonly IRedisConnectionFactory _connectionFactory;
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,
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.");
}
if (!string.Equals(_options.Driver, "redis", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"RedisPlatformEventPublisher cannot be used with driver '{_options.Driver}'.");
}
_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(OrchestratorEvent @event, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(@event);
cancellationToken.ThrowIfCancellationRequested();
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var payload = OrchestratorEventSerializer.Serialize(@event);
var entries = new NameValueEntry[]
{
new("event", payload),
new("kind", @event.Kind),
new("tenant", @event.Tenant),
new("occurredAt", @event.OccurredAt.ToString("O")),
new("idempotencyKey", @event.IdempotencyKey)
};
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 _connectionFactory.ConnectAsync(config, 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,583 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class ReportEventDispatcher : IReportEventDispatcher
{
private const string DefaultTenant = "default";
private const string Source = "scanner.webservice";
private readonly IPlatformEventPublisher _publisher;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ReportEventDispatcher> _logger;
private readonly string[] _apiBaseSegments;
private readonly string _reportsSegment;
private readonly string _policySegment;
public ReportEventDispatcher(
IPlatformEventPublisher publisher,
IOptions<ScannerWebServiceOptions> options,
TimeProvider timeProvider,
ILogger<ReportEventDispatcher> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
var apiOptions = options.Value.Api ?? new ScannerWebServiceOptions.ApiOptions();
_apiBaseSegments = SplitSegments(apiOptions.BasePath);
_reportsSegment = string.IsNullOrWhiteSpace(apiOptions.ReportsSegment)
? "reports"
: apiOptions.ReportsSegment.Trim('/');
_policySegment = string.IsNullOrWhiteSpace(apiOptions.PolicySegment)
? "policy"
: apiOptions.PolicySegment.Trim('/');
_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 occurredAt = document.GeneratedAt == default ? now : document.GeneratedAt;
var tenant = ResolveTenant(httpContext);
var scope = BuildScope(request, document);
var attributes = BuildAttributes(document);
var links = BuildLinks(httpContext, document, envelope);
var correlationId = document.ReportId;
var (traceId, spanId) = ResolveTraceContext();
var reportEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerReportReady,
Version = 1,
Tenant = tenant,
OccurredAt = occurredAt,
RecordedAt = now,
Source = Source,
IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerReportReady, tenant, document.ReportId),
CorrelationId = correlationId,
TraceId = traceId,
SpanId = spanId,
Scope = scope,
Attributes = attributes,
Payload = BuildReportReadyPayload(request, preview, document, envelope, links, correlationId)
};
await PublishSafelyAsync(reportEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
var scanCompletedEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerScanCompleted,
Version = 1,
Tenant = tenant,
OccurredAt = occurredAt,
RecordedAt = now,
Source = Source,
IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerScanCompleted, tenant, correlationId),
CorrelationId = correlationId,
TraceId = traceId,
SpanId = spanId,
Scope = scope,
Attributes = attributes,
Payload = BuildScanCompletedPayload(request, preview, document, envelope, links, correlationId)
};
await PublishSafelyAsync(scanCompletedEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
}
private async Task PublishSafelyAsync(OrchestratorEvent @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 orchestrator 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 OrchestratorEventScope 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 new OrchestratorEventScope
{
Namespace = ns,
Repo = string.IsNullOrWhiteSpace(repo) ? "(unknown)" : repo,
Digest = string.IsNullOrWhiteSpace(digest) ? "(unknown)" : digest
};
}
private static ImmutableSortedDictionary<string, string> BuildAttributes(ReportDocumentDto document)
{
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["reportId"] = document.ReportId;
builder["verdict"] = document.Verdict;
if (!string.IsNullOrWhiteSpace(document.Policy.RevisionId))
{
builder["policyRevisionId"] = document.Policy.RevisionId!;
}
if (!string.IsNullOrWhiteSpace(document.Policy.Digest))
{
builder["policyDigest"] = document.Policy.Digest!;
}
return builder.ToImmutable();
}
private static ReportReadyEventPayload BuildReportReadyPayload(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
ReportLinksPayload links,
string correlationId)
{
return new ReportReadyEventPayload
{
ReportId = document.ReportId,
ScanId = correlationId,
ImageDigest = document.ImageDigest,
GeneratedAt = document.GeneratedAt,
Verdict = MapVerdict(document.Verdict),
Summary = document.Summary,
Delta = BuildDelta(preview, request),
QuietedFindingCount = document.Summary.Quieted,
Policy = document.Policy,
Links = links,
Dsse = envelope,
Report = document
};
}
private static ScanCompletedEventPayload BuildScanCompletedPayload(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
DsseEnvelopeDto? envelope,
ReportLinksPayload links,
string correlationId)
{
return new ScanCompletedEventPayload
{
ReportId = document.ReportId,
ScanId = correlationId,
ImageDigest = document.ImageDigest,
Verdict = MapVerdict(document.Verdict),
Summary = document.Summary,
Delta = BuildDelta(preview, request),
Policy = document.Policy,
Findings = BuildFindingSummaries(request),
Links = links,
Dsse = envelope,
Report = document
};
}
private ReportLinksPayload BuildLinks(HttpContext context, ReportDocumentDto document, DsseEnvelopeDto? envelope)
{
if (!context.Request.Host.HasValue)
{
return new ReportLinksPayload();
}
var uiLink = BuildAbsoluteUri(context, "ui", "reports", document.ReportId);
var reportLink = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _reportsSegment, document.ReportId));
var policyLink = string.IsNullOrWhiteSpace(document.Policy.RevisionId)
? null
: BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _policySegment, "revisions", document.Policy.RevisionId));
var attestationLink = envelope is null
? null
: BuildAbsoluteUri(context, "ui", "attestations", document.ReportId);
return new ReportLinksPayload
{
Ui = uiLink,
Report = reportLink,
Policy = policyLink,
Attestation = attestationLink
};
}
private static ReportDeltaPayload? BuildDelta(PolicyPreviewResponse preview, ReportRequestDto request)
{
if (preview.Diffs.IsDefaultOrEmpty)
{
return null;
}
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;
}
findings.TryGetValue(projected.FindingId, out var finding);
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 && newHigh == 0 && kevIds.Count == 0)
{
return null;
}
return new ReportDeltaPayload
{
NewCritical = newCritical > 0 ? newCritical : null,
NewHigh = newHigh > 0 ? newHigh : null,
Kev = kevIds.Count > 0 ? kevIds.ToArray() : null
};
}
private static string BuildAbsoluteUri(HttpContext context, params string[] segments)
=> BuildAbsoluteUri(context, segments.AsEnumerable());
private static string BuildAbsoluteUri(HttpContext context, IEnumerable<string> segments)
{
var normalized = segments
.Where(segment => !string.IsNullOrWhiteSpace(segment))
.Select(segment => segment.Trim('/'))
.Where(segment => segment.Length > 0)
.ToArray();
if (!context.Request.Host.HasValue || normalized.Length == 0)
{
return string.Empty;
}
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 = "/" + string.Join('/', normalized.Select(Uri.EscapeDataString)),
Query = string.Empty,
Fragment = string.Empty
};
return builder.Uri.ToString();
}
private string[] ConcatSegments(IEnumerable<string> prefix, params string[] suffix)
{
var segments = new List<string>();
foreach (var segment in prefix)
{
if (!string.IsNullOrWhiteSpace(segment))
{
segments.Add(segment.Trim('/'));
}
}
foreach (var segment in suffix)
{
if (!string.IsNullOrWhiteSpace(segment))
{
segments.Add(segment.Trim('/'));
}
}
return segments.ToArray();
}
private static string[] SplitSegments(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return Array.Empty<string>();
}
return path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
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 IReadOnlyList<FindingSummaryPayload> BuildFindingSummaries(ReportRequestDto request)
{
if (request.Findings is not { Count: > 0 })
{
return Array.Empty<FindingSummaryPayload>();
}
var summaries = new List<FindingSummaryPayload>(request.Findings.Count);
foreach (var finding in request.Findings)
{
if (string.IsNullOrWhiteSpace(finding.Id))
{
continue;
}
summaries.Add(new FindingSummaryPayload
{
Id = finding.Id,
Severity = finding.Severity,
Cve = finding.Cve,
Purl = finding.Purl,
Reachability = ResolveReachability(finding.Tags)
});
}
return summaries;
}
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 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 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 string MapVerdict(string verdict)
=> verdict.ToLowerInvariant() switch
{
"blocked" or "fail" => "fail",
"escalated" => "fail",
"warn" or "warned" or "deferred" or "requiresvex" => "warn",
_ => "pass"
};
private static string BuildIdempotencyKey(string kind, string tenant, string identifier)
=> $"{kind}:{tenant}:{identifier}".ToLowerInvariant();
private static (string? TraceId, string? SpanId) ResolveTraceContext()
{
var activity = Activity.Current;
if (activity is null)
{
return (null, null);
}
var traceId = activity.TraceId.ToString();
var spanId = activity.SpanId.ToString();
return (traceId, spanId);
}
}

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

@@ -0,0 +1,215 @@
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 normalizedDigest = ExtractImageDigest(runtimeEvent);
var normalizedBuildId = NormalizeBuildId(runtimeEvent.Process?.BuildId);
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,
ImageDigest = normalizedDigest,
Engine = runtimeEvent.Runtime.Engine,
EngineVersion = runtimeEvent.Runtime.Version,
BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest,
ImageSigned = runtimeEvent.Posture?.ImageSigned,
SbomReferrer = runtimeEvent.Posture?.SbomReferrer,
BuildId = normalizedBuildId,
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);
}
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
{
var digest = NormalizeDigest(runtimeEvent.Delta?.BaselineImageDigest);
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
var imageRef = runtimeEvent.Workload.ImageRef;
if (string.IsNullOrWhiteSpace(imageRef))
{
return null;
}
var trimmed = imageRef.Trim();
var atIndex = trimmed.LastIndexOf('@');
if (atIndex >= 0 && atIndex < trimmed.Length - 1)
{
var candidate = trimmed[(atIndex + 1)..];
var parsed = NormalizeDigest(candidate);
if (!string.IsNullOrWhiteSpace(parsed))
{
return parsed;
}
}
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return NormalizeDigest(trimmed);
}
return null;
}
private static string? NormalizeDigest(string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return null;
}
var trimmed = candidate.Trim();
if (!trimmed.Contains(':', StringComparison.Ordinal))
{
return null;
}
return trimmed.ToLowerInvariant();
}
private static string? NormalizeBuildId(string? buildId)
{
if (string.IsNullOrWhiteSpace(buildId))
{
return null;
}
return buildId.Trim().ToLowerInvariant();
}
}
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,513 @@
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
using CanonicalPolicyVerdict = StellaOps.Policy.PolicyVerdict;
using CanonicalPolicyVerdictStatus = StellaOps.Policy.PolicyVerdictStatus;
namespace StellaOps.Scanner.WebService.Services;
internal interface IRuntimePolicyService
{
Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
}
internal sealed class RuntimePolicyService : IRuntimePolicyService
{
private const int MaxBuildIdsPerImage = 3;
private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0");
private static readonly Counter<long> PolicyEvaluations = PolicyMeter.CreateCounter<long>("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed.");
private static readonly Histogram<double> PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram<double>("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations.");
private readonly LinkRepository _linkRepository;
private readonly ArtifactRepository _artifactRepository;
private readonly RuntimeEventRepository _runtimeEventRepository;
private readonly PolicySnapshotStore _policySnapshotStore;
private readonly PolicyPreviewService _policyPreviewService;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
private readonly IRuntimeAttestationVerifier _attestationVerifier;
private readonly ILogger<RuntimePolicyService> _logger;
public RuntimePolicyService(
LinkRepository linkRepository,
ArtifactRepository artifactRepository,
RuntimeEventRepository runtimeEventRepository,
PolicySnapshotStore policySnapshotStore,
PolicyPreviewService policyPreviewService,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider,
IRuntimeAttestationVerifier attestationVerifier,
ILogger<RuntimePolicyService> logger)
{
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
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 stopwatch = Stopwatch.StartNew();
var snapshot = await _policySnapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
var policyRevision = snapshot?.RevisionId;
var policyDigest = snapshot?.Digest;
var results = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
var evaluationTags = new KeyValuePair<string, object?>[]
{
new("policy_revision", policyRevision ?? "none"),
new("namespace", request.Namespace ?? "unspecified")
};
var buildIdObservations = await _runtimeEventRepository
.GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken)
.ConfigureAwait(false);
try
{
var evaluated = new HashSet<string>(StringComparer.Ordinal);
foreach (var image in request.Images)
{
if (!evaluated.Add(image))
{
continue;
}
var metadata = await ResolveImageMetadataAsync(image, cancellationToken).ConfigureAwait(false);
var (findings, heuristicReasons) = BuildFindings(image, metadata, request.Namespace);
if (snapshot is null)
{
heuristicReasons.Add("policy.snapshot.missing");
}
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts = ImmutableArray<CanonicalPolicyVerdict>.Empty;
ImmutableArray<PolicyIssue> issues = ImmutableArray<PolicyIssue>.Empty;
try
{
if (!findings.IsDefaultOrEmpty && findings.Length > 0)
{
var previewRequest = new PolicyPreviewRequest(
image,
findings,
ImmutableArray<CanonicalPolicyVerdict>.Empty,
snapshot,
ProposedPolicy: null);
var preview = await _policyPreviewService.PreviewAsync(previewRequest, cancellationToken).ConfigureAwait(false);
issues = preview.Issues;
if (!preview.Diffs.IsDefaultOrEmpty)
{
projectedVerdicts = preview.Diffs.Select(diff => diff.Projected).ToImmutableArray();
}
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
}
var normalizedImage = image.Trim().ToLowerInvariant();
buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation);
var decision = await BuildDecisionAsync(
image,
metadata,
heuristicReasons,
projectedVerdicts,
issues,
policyDigest,
buildIdObservation?.BuildIds,
cancellationToken).ConfigureAwait(false);
results[image] = decision;
_logger.LogInformation("Runtime policy evaluated image {ImageDigest} with verdict {Verdict} (Signed: {Signed}, HasSbom: {HasSbom}, Reasons: {ReasonsCount})",
image,
decision.PolicyVerdict,
decision.Signed,
decision.HasSbomReferrers,
decision.Reasons.Count);
}
}
finally
{
stopwatch.Stop();
PolicyEvaluationLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds, evaluationTags);
}
PolicyEvaluations.Add(results.Count, evaluationTags);
var evaluationResult = new RuntimePolicyEvaluationResult(
ttlSeconds,
expiresAt,
policyRevision,
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(results));
return evaluationResult;
}
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 (ImmutableArray<PolicyFinding> Findings, List<string> HeuristicReasons) BuildFindings(string imageDigest, RuntimeImageMetadata metadata, string? @namespace)
{
var findings = ImmutableArray.CreateBuilder<PolicyFinding>();
var heuristics = new List<string>();
findings.Add(PolicyFinding.Create(
$"{imageDigest}#baseline",
PolicySeverity.None,
environment: @namespace,
source: "scanner.runtime"));
if (metadata.MissingMetadata)
{
const string reason = "image.metadata.missing";
heuristics.Add(reason);
findings.Add(PolicyFinding.Create(
$"{imageDigest}#metadata",
PolicySeverity.Critical,
environment: @namespace,
source: "scanner.runtime",
tags: ImmutableArray.Create(reason)));
}
if (!metadata.Signed)
{
const string reason = "unsigned";
heuristics.Add(reason);
findings.Add(PolicyFinding.Create(
$"{imageDigest}#signature",
PolicySeverity.High,
environment: @namespace,
source: "scanner.runtime",
tags: ImmutableArray.Create(reason)));
}
if (!metadata.HasSbomReferrers)
{
const string reason = "missing SBOM";
heuristics.Add(reason);
findings.Add(PolicyFinding.Create(
$"{imageDigest}#sbom",
PolicySeverity.High,
environment: @namespace,
source: "scanner.runtime",
tags: ImmutableArray.Create(reason)));
}
return (findings.ToImmutable(), heuristics);
}
private async Task<RuntimePolicyImageDecision> BuildDecisionAsync(
string imageDigest,
RuntimeImageMetadata metadata,
List<string> heuristicReasons,
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
ImmutableArray<PolicyIssue> issues,
string? policyDigest,
IReadOnlyList<string>? buildIds,
CancellationToken cancellationToken)
{
var reasons = new List<string>(heuristicReasons);
var overallVerdict = MapVerdict(projectedVerdicts, heuristicReasons);
if (!projectedVerdicts.IsDefaultOrEmpty)
{
foreach (var verdict in projectedVerdicts)
{
if (verdict.Status == CanonicalPolicyVerdictStatus.Pass)
{
continue;
}
if (!string.IsNullOrWhiteSpace(verdict.RuleName))
{
reasons.Add($"policy.rule.{verdict.RuleName}");
}
else
{
reasons.Add($"policy.status.{verdict.Status.ToString().ToLowerInvariant()}");
}
}
}
var confidence = ComputeConfidence(projectedVerdicts, overallVerdict);
var quieted = !projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Any(v => v.Quiet);
var quietedBy = !projectedVerdicts.IsDefaultOrEmpty
? projectedVerdicts.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v.QuietedBy))?.QuietedBy
: null;
var metadataPayload = BuildMetadataPayload(heuristicReasons, projectedVerdicts, issues, policyDigest);
var rekor = metadata.Rekor;
var verified = await _attestationVerifier.VerifyAsync(imageDigest, metadata.Rekor, cancellationToken).ConfigureAwait(false);
if (rekor is not null && verified.HasValue)
{
rekor = rekor with { Verified = verified.Value };
}
var normalizedReasons = reasons
.Where(reason => !string.IsNullOrWhiteSpace(reason))
.Distinct(StringComparer.Ordinal)
.ToArray();
return new RuntimePolicyImageDecision(
overallVerdict,
metadata.Signed,
metadata.HasSbomReferrers,
normalizedReasons,
rekor,
metadataPayload,
confidence,
quieted,
quietedBy,
buildIds);
}
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
{
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
{
var statuses = projectedVerdicts.Select(v => v.Status).ToArray();
if (statuses.Any(status => status == CanonicalPolicyVerdictStatus.Blocked))
{
return RuntimePolicyVerdict.Fail;
}
if (statuses.Any(status =>
status is CanonicalPolicyVerdictStatus.Warned
or CanonicalPolicyVerdictStatus.Deferred
or CanonicalPolicyVerdictStatus.Escalated
or CanonicalPolicyVerdictStatus.RequiresVex))
{
return RuntimePolicyVerdict.Warn;
}
return RuntimePolicyVerdict.Pass;
}
if (heuristicReasons.Contains("image.metadata.missing", StringComparer.Ordinal) ||
heuristicReasons.Contains("unsigned", StringComparer.Ordinal) ||
heuristicReasons.Contains("missing SBOM", StringComparer.Ordinal))
{
return RuntimePolicyVerdict.Fail;
}
if (heuristicReasons.Contains("policy.snapshot.missing", StringComparer.Ordinal))
{
return RuntimePolicyVerdict.Warn;
}
return RuntimePolicyVerdict.Pass;
}
private IDictionary<string, object?>? BuildMetadataPayload(
IReadOnlyList<string> heuristics,
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
ImmutableArray<PolicyIssue> issues,
string? policyDigest)
{
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["heuristics"] = heuristics,
["evaluatedAt"] = _timeProvider.GetUtcNow().UtcDateTime
};
if (!string.IsNullOrWhiteSpace(policyDigest))
{
payload["policyDigest"] = policyDigest;
}
if (!issues.IsDefaultOrEmpty && issues.Length > 0)
{
payload["issues"] = issues.Select(issue => new
{
code = issue.Code,
severity = issue.Severity.ToString(),
message = issue.Message,
path = issue.Path
}).ToArray();
}
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
{
payload["findings"] = projectedVerdicts.Select(verdict => new
{
id = verdict.FindingId,
status = verdict.Status.ToString().ToLowerInvariant(),
rule = verdict.RuleName,
action = verdict.RuleAction,
score = verdict.Score,
quiet = verdict.Quiet,
quietedBy = verdict.QuietedBy,
inputs = verdict.GetInputs(),
confidence = verdict.UnknownConfidence,
confidenceBand = verdict.ConfidenceBand,
sourceTrust = verdict.SourceTrust,
reachability = verdict.Reachability
}).ToArray();
}
return payload.Count == 0 ? null : payload;
}
private static double ComputeConfidence(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, RuntimePolicyVerdict overall)
{
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
{
var confidences = projectedVerdicts
.Select(v => v.UnknownConfidence)
.Where(value => value.HasValue)
.Select(value => value!.Value)
.ToArray();
if (confidences.Length > 0)
{
return Math.Clamp(confidences.Average(), 0.0, 1.0);
}
}
return overall switch
{
RuntimePolicyVerdict.Pass => 0.95,
RuntimePolicyVerdict.Warn => 0.5,
RuntimePolicyVerdict.Fail => 0.1,
_ => 0.25
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value;
}
internal interface IRuntimeAttestationVerifier
{
ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken);
}
internal sealed class RuntimeAttestationVerifier : IRuntimeAttestationVerifier
{
private readonly ILogger<RuntimeAttestationVerifier> _logger;
public RuntimeAttestationVerifier(ILogger<RuntimeAttestationVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken)
{
if (rekor is null)
{
return ValueTask.FromResult<bool?>(null);
}
if (rekor.Verified.HasValue)
{
return ValueTask.FromResult(rekor.Verified);
}
_logger.LogDebug("No attestation verification metadata available for image {ImageDigest}.", imageDigest);
return ValueTask.FromResult<bool?>(null);
}
}
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(
RuntimePolicyVerdict PolicyVerdict,
bool Signed,
bool HasSbomReferrers,
IReadOnlyList<string> Reasons,
RuntimePolicyRekorReference? Rekor,
IDictionary<string, object?>? Metadata,
double Confidence,
bool Quieted,
string? QuietedBy,
IReadOnlyList<string>? BuildIds);
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

@@ -0,0 +1,150 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
public interface IScanProgressPublisher
{
ScanProgressEvent Publish(
ScanId scanId,
string state,
string? message = null,
IReadOnlyDictionary<string, object?>? data = null,
string? correlationId = null);
}
public interface IScanProgressReader
{
bool Exists(ScanId scanId);
IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(ScanId scanId, CancellationToken cancellationToken);
}
public sealed class ScanProgressStream : IScanProgressPublisher, IScanProgressReader
{
private sealed class ProgressChannel
{
private readonly List<ScanProgressEvent> history = new();
private readonly Channel<ScanProgressEvent> channel = Channel.CreateUnbounded<ScanProgressEvent>(new UnboundedChannelOptions
{
AllowSynchronousContinuations = true,
SingleReader = false,
SingleWriter = false
});
public int Sequence { get; private set; }
public ScanProgressEvent Append(ScanProgressEvent progressEvent)
{
history.Add(progressEvent);
channel.Writer.TryWrite(progressEvent);
return progressEvent;
}
public IReadOnlyList<ScanProgressEvent> Snapshot()
{
return history.Count == 0
? Array.Empty<ScanProgressEvent>()
: history.ToArray();
}
public ChannelReader<ScanProgressEvent> Reader => channel.Reader;
public int NextSequence() => ++Sequence;
}
private static readonly IReadOnlyDictionary<string, object?> EmptyData =
new ReadOnlyDictionary<string, object?>(new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
private readonly ConcurrentDictionary<string, ProgressChannel> channels = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
public ScanProgressStream(TimeProvider timeProvider)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public bool Exists(ScanId scanId)
=> channels.ContainsKey(scanId.Value);
public ScanProgressEvent Publish(
ScanId scanId,
string state,
string? message = null,
IReadOnlyDictionary<string, object?>? data = null,
string? correlationId = null)
{
var channel = channels.GetOrAdd(scanId.Value, _ => new ProgressChannel());
ScanProgressEvent progressEvent;
lock (channel)
{
var sequence = channel.NextSequence();
var correlation = correlationId ?? $"{scanId.Value}:{sequence:D4}";
progressEvent = new ScanProgressEvent(
scanId,
sequence,
timeProvider.GetUtcNow(),
state,
message,
correlation,
NormalizePayload(data));
channel.Append(progressEvent);
}
return progressEvent;
}
public async IAsyncEnumerable<ScanProgressEvent> SubscribeAsync(
ScanId scanId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (!channels.TryGetValue(scanId.Value, out var channel))
{
yield break;
}
IReadOnlyList<ScanProgressEvent> snapshot;
lock (channel)
{
snapshot = channel.Snapshot();
}
foreach (var progressEvent in snapshot)
{
yield return progressEvent;
}
var reader = channel.Reader;
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (reader.TryRead(out var progressEvent))
{
yield return progressEvent;
}
}
}
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

@@ -0,0 +1,34 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Scanner.WebService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-rc.2.25502.107" />
<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.8.24" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="../../Zastava/__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
# Scanner WebService Task Board
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| 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 | 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 | 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 | 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 | DONE (2025-10-24) | 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 | DONE (2025-10-24) | 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 now pipes findings through `PolicyPreviewService`, emits canonical verdicts/confidence/quiet metadata, and updated tests cover pass/warn/fail paths + CLI contract fixtures. |
| SCANNER-RUNTIME-12-304 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | Surface attestation verification status by integrating Authority/Attestor Rekor validation (beyond presence-only). | `/policy/runtime` maps Rekor UUIDs through the runtime attestation verifier so `rekor.verified` reflects attestor outcomes; webhook/CLI coverage added. |
| SCANNER-RUNTIME-12-305 | DONE (2025-10-24) | 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`. | Runtime policy integration test + CLI-aligned fixture assert confidence, metadata JSON, and Rekor verification; docs note shared contract. |
| 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-26) | Scanner WebService Guild | ORCH-SVC-38-101, NOTIFY-SVC-38-001 | Emit orchestrator-compatible envelopes (`scanner.event.*`) and update integration tests to verify Notifier ingestion (no Redis queue coupling). | Tests assert envelope schema + orchestrator publish; Notifier consumer harness passes; docs updated with new event contract. Blocked by .NET 10 preview OpenAPI/Auth dependency drift preventing `dotnet test` completion. |
| SCANNER-EVENTS-16-302 | DOING (2025-10-26) | Scanner WebService Guild | SCANNER-EVENTS-16-301 | Extend orchestrator event links (report/policy/attestation) once endpoints are finalised across gateway + console. | Links section covers UI/API targets; downstream consumers validated; docs/samples updated. |
| SCANNER-RUNTIME-17-401 | DONE (2025-10-25) | 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. | Runtime events store normalized digests + build IDs with supporting indexes, runtime policy responses surface `buildIds`, tests/docs updated, and CLI/API consumers can derive debug-store paths deterministically. |
## Graph Explorer v1 (Sprint 21)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-GRAPH-21-001 | TODO | Scanner WebService Guild, Cartographer Guild | CARTO-GRAPH-21-007, SCHED-WEB-21-001 | Provide webhook/REST endpoint for Cartographer to request policy overlays and runtime evidence for graph nodes, ensuring determinism and tenant scoping. | Endpoint documented; integration tests cover Cartographer workflow; unauthorized access blocked. |
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-LNM-21-001 | TODO | Scanner WebService Guild, Policy Guild | POLICY-ENGINE-40-001 | Update `/reports` and `/policy/runtime` payloads to consume advisory/vex linksets, exposing source severity arrays and conflict summaries alongside effective verdicts. | API schema updated; clients regenerated; integration tests cover multiple source severities. |
| SCANNER-LNM-21-002 | TODO | Scanner WebService Guild, UI Guild | SCANNER-LNM-21-001 | Add evidence endpoint for Console to fetch linkset summaries with policy overlay for a component/SBOM, including AOC references. | Endpoint documented; UI integration passes; RBAC/tenancy enforced. |
## 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/Scanner/__Tests/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.
- 2025-10-24: `/policy/runtime` now streams through PolicyPreviewService + attestation verifier; CLI and webhook fixtures updated alongside Zastava observer batching completion.
- 2025-10-26: SCANNER-EVENTS-16-302 populates orchestrator link payloads (UI, API report lookup, policy revision, attestation) pending cross-service integration; samples/tests updated.
- 2025-10-26: Coordinate with Gateway + Console owners to confirm final API/UX paths for report, policy revision, and attestation links before promoting SCANNER-EVENTS-16-301 out of BLOCKED.
- 2025-10-26: SCANNER-EVENTS-16-301 emitting new orchestrator envelopes; solution-wide `dotnet test` currently blocked by preview `Microsoft.AspNetCore.OpenApi` APIs and missing `StellaOps.Auth` dependency wiring. JSON Schemas validated via `ajv`; service-level verification pending SDK alignment.

View File

@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Utilities;
internal static class ScanIdGenerator
{
public static ScanId Create(
ScanTarget target,
bool force,
string? clientRequestId,
IReadOnlyDictionary<string, string>? metadata)
{
ArgumentNullException.ThrowIfNull(target);
var builder = new StringBuilder();
builder.Append('|');
builder.Append(target.Reference?.Trim().ToLowerInvariant() ?? string.Empty);
builder.Append('|');
builder.Append(target.Digest?.Trim().ToLowerInvariant() ?? string.Empty);
builder.Append("|force:");
builder.Append(force ? '1' : '0');
builder.Append("|client:");
builder.Append(clientRequestId?.Trim().ToLowerInvariant() ?? string.Empty);
if (metadata is not null && metadata.Count > 0)
{
foreach (var pair in metadata.OrderBy(static entry => entry.Key, StringComparer.OrdinalIgnoreCase))
{
var key = pair.Key?.Trim().ToLowerInvariant() ?? string.Empty;
var value = pair.Value?.Trim() ?? string.Empty;
builder.Append('|');
builder.Append(key);
builder.Append('=');
builder.Append(value);
}
}
var canonical = builder.ToString();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
var hex = Convert.ToHexString(hash).ToLowerInvariant();
var trimmed = hex.Length > 40 ? hex[..40] : hex;
return new ScanId(trimmed);
}
}

View File

@@ -0,0 +1,26 @@
# AGENTS
## Role
Scanner.Worker engineers own the queue-driven execution host that turns scan jobs into SBOM artefacts with deterministic progress reporting.
## Scope
- Host bootstrap: configuration binding, Authority client wiring, graceful shutdown, restart-time plug-in discovery hooks.
- Job acquisition & lease renewal semantics backed by the Scanner queue abstraction.
- Analyzer orchestration skeleton: stage pipeline, cancellation awareness, deterministic progress emissions.
- Telemetry: structured logging, OpenTelemetry metrics/traces, health counters for offline diagnostics.
## Participants
- Consumes jobs from `StellaOps.Scanner.Queue`.
- Persists progress/artifacts via `StellaOps.Scanner.Storage` once those modules land.
- Emits metrics and structured logs consumed by Observability stack & WebService status endpoints.
## Interfaces & contracts
- Queue lease abstraction (`IScanJobLease`, `IScanJobSource`) with deterministic identifiers and attempt counters.
- Analyzer dispatcher contracts for OS/lang/native analyzers and emitters.
- Telemetry resource attributes shared with Scanner.WebService and Scheduler.
## In/Out of scope
In scope: worker host, concurrency orchestration, lease renewal, cancellation wiring, deterministic logging/metrics.
Out of scope: queue provider implementations, analyzer business logic, Mongo/object-store repositories.
## Observability expectations
- Meter `StellaOps.Scanner.Worker` with queue latency, stage duration, failure counters.
- Activity source `StellaOps.Scanner.Worker.Job` for per-job tracing.
- Log correlation IDs (`jobId`, `leaseId`, `scanId`) with structured payloads; avoid dumping secrets or full manifests.
## Tests
- Integration fixture `WorkerBasicScanScenario` verifying acquisition → heartbeat → analyzer stages → completion.
- Unit tests around retry/jitter calculators as they are introduced.

View File

@@ -0,0 +1,15 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.Worker.Diagnostics;
public static class ScannerWorkerInstrumentation
{
public const string ActivitySourceName = "StellaOps.Scanner.Worker.Job";
public const string MeterName = "StellaOps.Scanner.Worker";
public static ActivitySource ActivitySource { get; } = new(ActivitySourceName);
public static Meter Meter { get; } = new(MeterName, version: "1.0.0");
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using StellaOps.Scanner.Worker.Processing;
namespace StellaOps.Scanner.Worker.Diagnostics;
public sealed class ScannerWorkerMetrics
{
private readonly Histogram<double> _queueLatencyMs;
private readonly Histogram<double> _jobDurationMs;
private readonly Histogram<double> _stageDurationMs;
private readonly Counter<long> _jobsCompleted;
private readonly Counter<long> _jobsFailed;
public ScannerWorkerMetrics()
{
_queueLatencyMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
"scanner_worker_queue_latency_ms",
unit: "ms",
description: "Time from job enqueue to lease acquisition.");
_jobDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
"scanner_worker_job_duration_ms",
unit: "ms",
description: "Total processing duration per job.");
_stageDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
"scanner_worker_stage_duration_ms",
unit: "ms",
description: "Stage execution duration per job.");
_jobsCompleted = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
"scanner_worker_jobs_completed_total",
description: "Number of successfully completed scan jobs.");
_jobsFailed = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
"scanner_worker_jobs_failed_total",
description: "Number of scan jobs that failed permanently.");
}
public void RecordQueueLatency(ScanJobContext context, TimeSpan latency)
{
if (latency <= TimeSpan.Zero)
{
return;
}
_queueLatencyMs.Record(latency.TotalMilliseconds, CreateTags(context));
}
public void RecordJobDuration(ScanJobContext context, TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return;
}
_jobDurationMs.Record(duration.TotalMilliseconds, CreateTags(context));
}
public void RecordStageDuration(ScanJobContext context, string stage, TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return;
}
_stageDurationMs.Record(duration.TotalMilliseconds, CreateTags(context, stage: stage));
}
public void IncrementJobCompleted(ScanJobContext context)
{
_jobsCompleted.Add(1, CreateTags(context));
}
public void IncrementJobFailed(ScanJobContext context, string failureReason)
{
_jobsFailed.Add(1, CreateTags(context, failureReason: failureReason));
}
private static KeyValuePair<string, object?>[] CreateTags(ScanJobContext context, string? stage = null, string? failureReason = null)
{
var tags = new List<KeyValuePair<string, object?>>(stage is null ? 5 : 6)
{
new("job.id", context.JobId),
new("scan.id", context.ScanId),
new("attempt", context.Lease.Attempt),
};
if (context.Lease.Metadata.TryGetValue("queue", out var queueName) && !string.IsNullOrWhiteSpace(queueName))
{
tags.Add(new KeyValuePair<string, object?>("queue", queueName));
}
if (context.Lease.Metadata.TryGetValue("job.kind", out var jobKind) && !string.IsNullOrWhiteSpace(jobKind))
{
tags.Add(new KeyValuePair<string, object?>("job.kind", jobKind));
}
if (!string.IsNullOrWhiteSpace(stage))
{
tags.Add(new KeyValuePair<string, object?>("stage", stage));
}
if (!string.IsNullOrWhiteSpace(failureReason))
{
tags.Add(new KeyValuePair<string, object?>("reason", failureReason));
}
return tags.ToArray();
}
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Diagnostics;
public static class TelemetryExtensions
{
public static void ConfigureScannerWorkerTelemetry(this IHostApplicationBuilder builder, ScannerWorkerOptions options)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(options);
var telemetry = options.Telemetry;
if (!telemetry.EnableTelemetry)
{
return;
}
var openTelemetry = builder.Services.AddOpenTelemetry();
openTelemetry.ConfigureResource(resource =>
{
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
resource.AddService(telemetry.ServiceName, serviceVersion: version, serviceInstanceId: Environment.MachineName);
resource.AddAttributes(new[]
{
new KeyValuePair<string, object>("deployment.environment", builder.Environment.EnvironmentName),
});
foreach (var kvp in telemetry.ResourceAttributes)
{
if (string.IsNullOrWhiteSpace(kvp.Key) || kvp.Value is null)
{
continue;
}
resource.AddAttributes(new[] { new KeyValuePair<string, object>(kvp.Key, kvp.Value) });
}
});
if (telemetry.EnableTracing)
{
openTelemetry.WithTracing(tracing =>
{
tracing.AddSource(ScannerWorkerInstrumentation.ActivitySourceName);
ConfigureExporter(tracing, telemetry);
});
}
if (telemetry.EnableMetrics)
{
openTelemetry.WithMetrics(metrics =>
{
metrics
.AddMeter(
ScannerWorkerInstrumentation.MeterName,
"StellaOps.Scanner.Analyzers.Lang.Node",
"StellaOps.Scanner.Analyzers.Lang.Go")
.AddRuntimeInstrumentation()
.AddProcessInstrumentation();
ConfigureExporter(metrics, telemetry);
});
}
}
private static void ConfigureExporter(TracerProviderBuilder tracing, ScannerWorkerOptions.TelemetryOptions telemetry)
{
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
{
tracing.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
});
}
if (telemetry.ExportConsole || string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
{
tracing.AddConsoleExporter();
}
}
private static void ConfigureExporter(MeterProviderBuilder metrics, ScannerWorkerOptions.TelemetryOptions telemetry)
{
if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
{
metrics.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
});
}
if (telemetry.ExportConsole || string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
{
metrics.AddConsoleExporter();
}
}
}

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
namespace StellaOps.Scanner.Worker.Hosting;
public sealed partial class ScannerWorkerHostedService : BackgroundService
{
private readonly IScanJobSource _jobSource;
private readonly ScanJobProcessor _processor;
private readonly LeaseHeartbeatService _heartbeatService;
private readonly ScannerWorkerMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
private readonly ILogger<ScannerWorkerHostedService> _logger;
private readonly IDelayScheduler _delayScheduler;
public ScannerWorkerHostedService(
IScanJobSource jobSource,
ScanJobProcessor processor,
LeaseHeartbeatService heartbeatService,
ScannerWorkerMetrics metrics,
TimeProvider timeProvider,
IDelayScheduler delayScheduler,
IOptionsMonitor<ScannerWorkerOptions> options,
ILogger<ScannerWorkerHostedService> logger)
{
_jobSource = jobSource ?? throw new ArgumentNullException(nameof(jobSource));
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
_heartbeatService = heartbeatService ?? throw new ArgumentNullException(nameof(heartbeatService));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_delayScheduler = delayScheduler ?? throw new ArgumentNullException(nameof(delayScheduler));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var runningJobs = new HashSet<Task>();
var delayStrategy = new PollDelayStrategy(_options.CurrentValue.Polling);
WorkerStarted(_logger);
while (!stoppingToken.IsCancellationRequested)
{
runningJobs.RemoveWhere(static task => task.IsCompleted);
var options = _options.CurrentValue;
if (runningJobs.Count >= options.MaxConcurrentJobs)
{
var completed = await Task.WhenAny(runningJobs).ConfigureAwait(false);
runningJobs.Remove(completed);
continue;
}
IScanJobLease? lease = null;
try
{
lease = await _jobSource.TryAcquireAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Scanner worker failed to acquire job lease; backing off.");
}
if (lease is null)
{
var delay = delayStrategy.NextDelay();
await _delayScheduler.DelayAsync(delay, stoppingToken).ConfigureAwait(false);
continue;
}
delayStrategy.Reset();
runningJobs.Add(RunJobAsync(lease, stoppingToken));
}
if (runningJobs.Count > 0)
{
await Task.WhenAll(runningJobs).ConfigureAwait(false);
}
WorkerStopping(_logger);
}
private async Task RunJobAsync(IScanJobLease lease, CancellationToken stoppingToken)
{
var options = _options.CurrentValue;
var jobStart = _timeProvider.GetUtcNow();
var queueLatency = jobStart - lease.EnqueuedAtUtc;
var jobCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
var jobToken = jobCts.Token;
var context = new ScanJobContext(lease, _timeProvider, jobStart, jobToken);
_metrics.RecordQueueLatency(context, queueLatency);
JobAcquired(_logger, lease.JobId, lease.ScanId, lease.Attempt, queueLatency.TotalMilliseconds);
var processingTask = _processor.ExecuteAsync(context, jobToken).AsTask();
var heartbeatTask = _heartbeatService.RunAsync(lease, jobToken);
Exception? processingException = null;
try
{
await processingTask.ConfigureAwait(false);
jobCts.Cancel();
await heartbeatTask.ConfigureAwait(false);
await lease.CompleteAsync(stoppingToken).ConfigureAwait(false);
var duration = _timeProvider.GetUtcNow() - jobStart;
_metrics.RecordJobDuration(context, duration);
_metrics.IncrementJobCompleted(context);
JobCompleted(_logger, lease.JobId, lease.ScanId, duration.TotalMilliseconds);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
processingException = null;
await lease.AbandonAsync("host-stopping", CancellationToken.None).ConfigureAwait(false);
JobAbandoned(_logger, lease.JobId, lease.ScanId);
}
catch (Exception ex)
{
processingException = ex;
var duration = _timeProvider.GetUtcNow() - jobStart;
_metrics.RecordJobDuration(context, duration);
var reason = ex.GetType().Name;
var maxAttempts = options.Queue.MaxAttempts;
if (lease.Attempt >= maxAttempts)
{
await lease.PoisonAsync(reason, CancellationToken.None).ConfigureAwait(false);
_metrics.IncrementJobFailed(context, reason);
JobPoisoned(_logger, lease.JobId, lease.ScanId, lease.Attempt, maxAttempts, ex);
}
else
{
await lease.AbandonAsync(reason, CancellationToken.None).ConfigureAwait(false);
JobAbandonedWithError(_logger, lease.JobId, lease.ScanId, lease.Attempt, maxAttempts, ex);
}
}
finally
{
jobCts.Cancel();
try
{
await heartbeatTask.ConfigureAwait(false);
}
catch (Exception ex) when (processingException is null && ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Heartbeat loop ended with an exception for job {JobId}.", lease.JobId);
}
await lease.DisposeAsync().ConfigureAwait(false);
jobCts.Dispose();
}
}
[LoggerMessage(EventId = 2000, Level = LogLevel.Information, Message = "Scanner worker host started.")]
private static partial void WorkerStarted(ILogger logger);
[LoggerMessage(EventId = 2001, Level = LogLevel.Information, Message = "Scanner worker host stopping.")]
private static partial void WorkerStopping(ILogger logger);
[LoggerMessage(
EventId = 2002,
Level = LogLevel.Information,
Message = "Leased job {JobId} (scan {ScanId}) attempt {Attempt}; queue latency {LatencyMs:F0} ms.")]
private static partial void JobAcquired(ILogger logger, string jobId, string scanId, int attempt, double latencyMs);
[LoggerMessage(
EventId = 2003,
Level = LogLevel.Information,
Message = "Job {JobId} (scan {ScanId}) completed in {DurationMs:F0} ms.")]
private static partial void JobCompleted(ILogger logger, string jobId, string scanId, double durationMs);
[LoggerMessage(
EventId = 2004,
Level = LogLevel.Warning,
Message = "Job {JobId} (scan {ScanId}) abandoned due to host shutdown.")]
private static partial void JobAbandoned(ILogger logger, string jobId, string scanId);
[LoggerMessage(
EventId = 2005,
Level = LogLevel.Warning,
Message = "Job {JobId} (scan {ScanId}) attempt {Attempt}/{MaxAttempts} abandoned after failure; job will be retried.")]
private static partial void JobAbandonedWithError(ILogger logger, string jobId, string scanId, int attempt, int maxAttempts, Exception exception);
[LoggerMessage(
EventId = 2006,
Level = LogLevel.Error,
Message = "Job {JobId} (scan {ScanId}) attempt {Attempt}/{MaxAttempts} exceeded retry budget; quarantining job.")]
private static partial void JobPoisoned(ILogger logger, string jobId, string scanId, int attempt, int maxAttempts, Exception exception);
}

View File

@@ -0,0 +1,175 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Worker.Options;
public sealed class ScannerWorkerOptions
{
public const string SectionName = "Scanner:Worker";
public int MaxConcurrentJobs { get; set; } = 2;
public QueueOptions Queue { get; } = new();
public PollingOptions Polling { get; } = new();
public AuthorityOptions Authority { get; } = new();
public TelemetryOptions Telemetry { get; } = new();
public ShutdownOptions Shutdown { get; } = new();
public AnalyzerOptions Analyzers { get; } = new();
public sealed class QueueOptions
{
public int MaxAttempts { get; set; } = 5;
public double HeartbeatSafetyFactor { get; set; } = 3.0;
public int MaxHeartbeatJitterMilliseconds { get; set; } = 750;
public IReadOnlyList<TimeSpan> HeartbeatRetryDelays => _heartbeatRetryDelays;
public TimeSpan MinHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan MaxHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(30);
public void SetHeartbeatRetryDelays(IEnumerable<TimeSpan> delays)
{
_heartbeatRetryDelays = NormalizeDelays(delays);
}
internal IReadOnlyList<TimeSpan> NormalizedHeartbeatRetryDelays => _heartbeatRetryDelays;
private static IReadOnlyList<TimeSpan> NormalizeDelays(IEnumerable<TimeSpan> delays)
{
var buffer = new List<TimeSpan>();
foreach (var delay in delays)
{
if (delay <= TimeSpan.Zero)
{
continue;
}
buffer.Add(delay);
}
buffer.Sort();
return new ReadOnlyCollection<TimeSpan>(buffer);
}
private IReadOnlyList<TimeSpan> _heartbeatRetryDelays = new ReadOnlyCollection<TimeSpan>(new TimeSpan[]
{
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
});
}
public sealed class PollingOptions
{
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(5);
public double JitterRatio { get; set; } = 0.2;
}
public sealed class AuthorityOptions
{
public bool Enabled { get; set; }
public string? Issuer { get; set; }
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public string? MetadataAddress { get; set; }
public int BackchannelTimeoutSeconds { get; set; } = 20;
public int TokenClockSkewSeconds { get; set; } = 30;
public IList<string> Scopes { get; } = new List<string> { "scanner.scan" };
public ResilienceOptions Resilience { get; } = new();
}
public sealed class ResilienceOptions
{
public bool? EnableRetries { get; set; }
public IList<TimeSpan> RetryDelays { get; } = new List<TimeSpan>
{
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(5),
};
public bool? AllowOfflineCacheFallback { get; set; }
public TimeSpan? OfflineCacheTolerance { get; set; }
}
public sealed class TelemetryOptions
{
public bool EnableLogging { get; set; } = true;
public bool EnableTelemetry { get; set; } = true;
public bool EnableTracing { get; set; }
public bool EnableMetrics { get; set; } = true;
public string ServiceName { get; set; } = "stellaops-scanner-worker";
public string? OtlpEndpoint { get; set; }
public bool ExportConsole { get; set; }
public IDictionary<string, string?> ResourceAttributes { get; } = new ConcurrentDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
public sealed class ShutdownOptions
{
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}
public sealed class AnalyzerOptions
{
public AnalyzerOptions()
{
PluginDirectories = new List<string>
{
Path.Combine("plugins", "scanner", "analyzers", "os"),
};
LanguagePluginDirectories = new List<string>
{
Path.Combine("plugins", "scanner", "analyzers", "lang"),
};
}
public IList<string> PluginDirectories { get; }
public IList<string> LanguagePluginDirectories { get; }
public string RootFilesystemMetadataKey { get; set; } = ScanMetadataKeys.RootFilesystemPath;
public string WorkspaceMetadataKey { get; set; } = ScanMetadataKeys.WorkspacePath;
public string EntryTraceConfigMetadataKey { get; set; } = ScanMetadataKeys.ImageConfigPath;
public string EntryTraceLayerDirectoriesMetadataKey { get; set; } = ScanMetadataKeys.LayerDirectories;
public string EntryTraceLayerArchivesMetadataKey { get; set; } = ScanMetadataKeys.LayerArchives;
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Worker.Options;
public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWorkerOptions>
{
public ValidateOptionsResult Validate(string? name, ScannerWorkerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var failures = new List<string>();
if (options.MaxConcurrentJobs <= 0)
{
failures.Add("Scanner.Worker:MaxConcurrentJobs must be greater than zero.");
}
if (options.Queue.HeartbeatSafetyFactor < 3.0)
{
failures.Add("Scanner.Worker:Queue:HeartbeatSafetyFactor must be at least 3.");
}
if (options.Queue.MaxAttempts <= 0)
{
failures.Add("Scanner.Worker:Queue:MaxAttempts must be greater than zero.");
}
if (options.Queue.MinHeartbeatInterval <= TimeSpan.Zero)
{
failures.Add("Scanner.Worker:Queue:MinHeartbeatInterval must be greater than zero.");
}
if (options.Queue.MaxHeartbeatInterval <= options.Queue.MinHeartbeatInterval)
{
failures.Add("Scanner.Worker:Queue:MaxHeartbeatInterval must be greater than MinHeartbeatInterval.");
}
if (options.Polling.InitialDelay <= TimeSpan.Zero)
{
failures.Add("Scanner.Worker:Polling:InitialDelay must be greater than zero.");
}
if (options.Polling.MaxDelay < options.Polling.InitialDelay)
{
failures.Add("Scanner.Worker:Polling:MaxDelay must be greater than or equal to InitialDelay.");
}
if (options.Polling.JitterRatio is < 0 or > 1)
{
failures.Add("Scanner.Worker:Polling:JitterRatio must be between 0 and 1.");
}
if (options.Authority.Enabled)
{
if (string.IsNullOrWhiteSpace(options.Authority.Issuer))
{
failures.Add("Scanner.Worker:Authority requires Issuer when Enabled is true.");
}
if (string.IsNullOrWhiteSpace(options.Authority.ClientId))
{
failures.Add("Scanner.Worker:Authority requires ClientId when Enabled is true.");
}
if (options.Authority.BackchannelTimeoutSeconds <= 0)
{
failures.Add("Scanner.Worker:Authority:BackchannelTimeoutSeconds must be greater than zero.");
}
if (options.Authority.TokenClockSkewSeconds < 0)
{
failures.Add("Scanner.Worker:Authority:TokenClockSkewSeconds cannot be negative.");
}
if (options.Authority.Resilience.RetryDelays.Any(delay => delay <= TimeSpan.Zero))
{
failures.Add("Scanner.Worker:Authority:Resilience:RetryDelays must be positive durations.");
}
}
if (options.Shutdown.Timeout < TimeSpan.FromSeconds(5))
{
failures.Add("Scanner.Worker:Shutdown:Timeout must be at least 5 seconds to allow lease completion.");
}
if (options.Telemetry.EnableTelemetry)
{
if (!options.Telemetry.EnableMetrics && !options.Telemetry.EnableTracing)
{
failures.Add("Scanner.Worker:Telemetry:EnableTelemetry requires metrics or tracing to be enabled.");
}
}
if (string.IsNullOrWhiteSpace(options.Analyzers.RootFilesystemMetadataKey))
{
failures.Add("Scanner.Worker:Analyzers:RootFilesystemMetadataKey must be provided.");
}
if (string.IsNullOrWhiteSpace(options.Analyzers.EntryTraceConfigMetadataKey))
{
failures.Add("Scanner.Worker:Analyzers:EntryTraceConfigMetadataKey must be provided.");
}
if (string.IsNullOrWhiteSpace(options.Analyzers.EntryTraceLayerDirectoriesMetadataKey)
&& string.IsNullOrWhiteSpace(options.Analyzers.EntryTraceLayerArchivesMetadataKey))
{
failures.Add("Scanner.Worker:Analyzers must specify EntryTrace layer directory or archive metadata keys.");
}
return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures);
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class AnalyzerStageExecutor : IScanStageExecutor
{
private readonly IScanAnalyzerDispatcher _dispatcher;
private readonly IEntryTraceExecutionService _entryTrace;
public AnalyzerStageExecutor(IScanAnalyzerDispatcher dispatcher, IEntryTraceExecutionService entryTrace)
{
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_entryTrace = entryTrace ?? throw new ArgumentNullException(nameof(entryTrace));
}
public string StageName => ScanStageNames.ExecuteAnalyzers;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
await _entryTrace.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
await _dispatcher.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Mapping;
using StellaOps.Scanner.Analyzers.OS.Plugin;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOSAnalyzerPluginCatalog _osCatalog;
private readonly ILanguageAnalyzerPluginCatalog _languageCatalog;
private readonly ScannerWorkerOptions _options;
private readonly ILogger<CompositeScanAnalyzerDispatcher> _logger;
private IReadOnlyList<string> _osPluginDirectories = Array.Empty<string>();
private IReadOnlyList<string> _languagePluginDirectories = Array.Empty<string>();
public CompositeScanAnalyzerDispatcher(
IServiceScopeFactory scopeFactory,
IOSAnalyzerPluginCatalog osCatalog,
ILanguageAnalyzerPluginCatalog languageCatalog,
IOptions<ScannerWorkerOptions> options,
ILogger<CompositeScanAnalyzerDispatcher> logger)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_osCatalog = osCatalog ?? throw new ArgumentNullException(nameof(osCatalog));
_languageCatalog = languageCatalog ?? throw new ArgumentNullException(nameof(languageCatalog));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
LoadPlugins();
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
using var scope = _scopeFactory.CreateScope();
var services = scope.ServiceProvider;
var osAnalyzers = _osCatalog.CreateAnalyzers(services);
var languageAnalyzers = _languageCatalog.CreateAnalyzers(services);
if (osAnalyzers.Count == 0 && languageAnalyzers.Count == 0)
{
_logger.LogWarning("No analyzer plug-ins available; skipping analyzer stage for job {JobId}.", context.JobId);
return;
}
var metadata = new Dictionary<string, string>(context.Lease.Metadata, StringComparer.Ordinal);
var rootfsPath = ResolvePath(metadata, _options.Analyzers.RootFilesystemMetadataKey);
var workspacePath = ResolvePath(metadata, _options.Analyzers.WorkspaceMetadataKey) ?? rootfsPath;
if (osAnalyzers.Count > 0)
{
await ExecuteOsAnalyzersAsync(context, osAnalyzers, services, rootfsPath, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
if (languageAnalyzers.Count > 0)
{
await ExecuteLanguageAnalyzersAsync(context, languageAnalyzers, services, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
}
private async Task ExecuteOsAnalyzersAsync(
ScanJobContext context,
IReadOnlyList<IOSPackageAnalyzer> analyzers,
IServiceProvider services,
string? rootfsPath,
string? workspacePath,
CancellationToken cancellationToken)
{
if (rootfsPath is null)
{
_logger.LogWarning(
"Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate root filesystem. OS analyzers skipped.",
_options.Analyzers.RootFilesystemMetadataKey,
context.JobId);
return;
}
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var results = new List<OSPackageAnalyzerResult>(analyzers.Count);
foreach (var analyzer in analyzers)
{
cancellationToken.ThrowIfCancellationRequested();
var analyzerLogger = loggerFactory.CreateLogger(analyzer.GetType());
var analyzerContext = new OSPackageAnalyzerContext(rootfsPath, workspacePath, context.TimeProvider, analyzerLogger, context.Lease.Metadata);
try
{
var result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Analyzer {AnalyzerId} failed for job {JobId}.", analyzer.AnalyzerId, context.JobId);
}
}
if (results.Count == 0)
{
return;
}
var dictionary = results.ToDictionary(result => result.AnalyzerId, StringComparer.OrdinalIgnoreCase);
context.Analysis.Set(ScanAnalysisKeys.OsPackageAnalyzers, dictionary);
var fragments = OsComponentMapper.ToLayerFragments(results);
if (!fragments.IsDefaultOrEmpty)
{
context.Analysis.AppendLayerFragments(fragments);
context.Analysis.Set(ScanAnalysisKeys.OsComponentFragments, fragments);
}
}
private async Task ExecuteLanguageAnalyzersAsync(
ScanJobContext context,
IReadOnlyList<ILanguageAnalyzer> analyzers,
IServiceProvider services,
string? workspacePath,
CancellationToken cancellationToken)
{
if (workspacePath is null)
{
_logger.LogWarning(
"Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate workspace. Language analyzers skipped.",
_options.Analyzers.WorkspaceMetadataKey,
context.JobId);
return;
}
var usageHints = LanguageUsageHints.Empty;
var analyzerContext = new LanguageAnalyzerContext(workspacePath, context.TimeProvider, usageHints, services);
var results = new Dictionary<string, LanguageAnalyzerResult>(StringComparer.OrdinalIgnoreCase);
var fragments = new List<LayerComponentFragment>();
foreach (var analyzer in analyzers)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var engine = new LanguageAnalyzerEngine(new[] { analyzer });
var result = await engine.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false);
results[analyzer.Id] = result;
var components = result.Components
.Where(component => string.Equals(component.AnalyzerId, analyzer.Id, StringComparison.Ordinal))
.ToArray();
if (components.Length > 0)
{
var fragment = LanguageComponentMapper.ToLayerFragment(analyzer.Id, components);
fragments.Add(fragment);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Language analyzer {AnalyzerId} failed for job {JobId}.", analyzer.Id, context.JobId);
}
}
if (results.Count == 0 && fragments.Count == 0)
{
return;
}
if (results.Count > 0)
{
context.Analysis.Set(
ScanAnalysisKeys.LanguageAnalyzerResults,
new ReadOnlyDictionary<string, LanguageAnalyzerResult>(results));
}
if (fragments.Count > 0)
{
var immutableFragments = ImmutableArray.CreateRange(fragments);
context.Analysis.AppendLayerFragments(immutableFragments);
context.Analysis.Set(ScanAnalysisKeys.LanguageComponentFragments, immutableFragments);
}
}
private void LoadPlugins()
{
_osPluginDirectories = NormalizeDirectories(_options.Analyzers.PluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "os"));
for (var i = 0; i < _osPluginDirectories.Count; i++)
{
var directory = _osPluginDirectories[i];
var seal = i == _osPluginDirectories.Count - 1;
try
{
_osCatalog.LoadFromDirectory(directory, seal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load OS analyzer plug-ins from {Directory}.", directory);
}
}
_languagePluginDirectories = NormalizeDirectories(_options.Analyzers.LanguagePluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "lang"));
for (var i = 0; i < _languagePluginDirectories.Count; i++)
{
var directory = _languagePluginDirectories[i];
var seal = i == _languagePluginDirectories.Count - 1;
try
{
_languageCatalog.LoadFromDirectory(directory, seal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load language analyzer plug-ins from {Directory}.", directory);
}
}
}
private static IReadOnlyList<string> NormalizeDirectories(IEnumerable<string> configured, string fallbackRelative)
{
var directories = new List<string>();
foreach (var configuredPath in configured ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
continue;
}
var path = configuredPath;
if (!Path.IsPathRooted(path))
{
path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path));
}
directories.Add(path);
}
if (directories.Count == 0)
{
var fallback = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, fallbackRelative));
directories.Add(fallback);
}
return new ReadOnlyCollection<string>(directories);
}
private static string? ResolvePath(IReadOnlyDictionary<string, string> metadata, string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return Path.IsPathRooted(trimmed)
? trimmed
: Path.GetFullPath(trimmed);
}
}

View File

@@ -0,0 +1,302 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.Worker.Options;
using IOPath = System.IO.Path;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
{
private readonly IEntryTraceAnalyzer _analyzer;
private readonly EntryTraceAnalyzerOptions _entryTraceOptions;
private readonly ScannerWorkerOptions _workerOptions;
private readonly ILogger<EntryTraceExecutionService> _logger;
private readonly ILoggerFactory _loggerFactory;
public EntryTraceExecutionService(
IEntryTraceAnalyzer analyzer,
IOptions<EntryTraceAnalyzerOptions> entryTraceOptions,
IOptions<ScannerWorkerOptions> workerOptions,
ILogger<EntryTraceExecutionService> logger,
ILoggerFactory loggerFactory)
{
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
_entryTraceOptions = (entryTraceOptions ?? throw new ArgumentNullException(nameof(entryTraceOptions))).Value ?? new EntryTraceAnalyzerOptions();
_workerOptions = (workerOptions ?? throw new ArgumentNullException(nameof(workerOptions))).Value ?? new ScannerWorkerOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var metadata = context.Lease.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
var configPath = ResolvePath(metadata, _workerOptions.Analyzers.EntryTraceConfigMetadataKey, ScanMetadataKeys.ImageConfigPath);
if (configPath is null)
{
_logger.LogDebug("EntryTrace config metadata '{MetadataKey}' missing for job {JobId}; skipping entry trace.", _workerOptions.Analyzers.EntryTraceConfigMetadataKey, context.JobId);
return;
}
if (!File.Exists(configPath))
{
_logger.LogWarning("EntryTrace config file '{ConfigPath}' not found for job {JobId}; skipping entry trace.", configPath, context.JobId);
return;
}
OciImageConfig config;
try
{
using var stream = File.OpenRead(configPath);
config = OciImageConfigLoader.Load(stream);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse OCI image config at '{ConfigPath}' for job {JobId}; entry trace skipped.", configPath, context.JobId);
return;
}
var fileSystem = BuildFileSystem(context.JobId, metadata);
if (fileSystem is null)
{
return;
}
var imageDigest = ResolveImageDigest(metadata, context);
var entryTraceLogger = _loggerFactory.CreateLogger<EntryTraceExecutionService>();
EntryTraceImageContext imageContext;
try
{
imageContext = EntryTraceImageContextFactory.Create(
config,
fileSystem,
_entryTraceOptions,
imageDigest,
context.ScanId,
entryTraceLogger);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to build EntryTrace context for job {JobId}; skipping entry trace.", context.JobId);
return;
}
EntryTraceGraph graph;
try
{
graph = await _analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "EntryTrace analyzer failed for job {JobId}.", context.JobId);
return;
}
context.Analysis.Set(ScanAnalysisKeys.EntryTraceGraph, graph);
}
private LayeredRootFileSystem? BuildFileSystem(string jobId, IReadOnlyDictionary<string, string> metadata)
{
var directoryValues = ResolveList(metadata, _workerOptions.Analyzers.EntryTraceLayerDirectoriesMetadataKey, ScanMetadataKeys.LayerDirectories);
var archiveValues = ResolveList(metadata, _workerOptions.Analyzers.EntryTraceLayerArchivesMetadataKey, ScanMetadataKeys.LayerArchives);
var directoryLayers = new List<LayeredRootFileSystem.LayerDirectory>();
foreach (var value in directoryValues)
{
var fullPath = NormalizePath(value);
if (string.IsNullOrWhiteSpace(fullPath))
{
continue;
}
if (!Directory.Exists(fullPath))
{
_logger.LogWarning("EntryTrace layer directory '{Directory}' not found for job {JobId}; skipping layer.", fullPath, jobId);
continue;
}
directoryLayers.Add(new LayeredRootFileSystem.LayerDirectory(TryDeriveDigest(fullPath) ?? string.Empty, fullPath));
}
var archiveLayers = new List<LayeredRootFileSystem.LayerArchive>();
foreach (var value in archiveValues)
{
var fullPath = NormalizePath(value);
if (string.IsNullOrWhiteSpace(fullPath))
{
continue;
}
if (!File.Exists(fullPath))
{
_logger.LogWarning("EntryTrace layer archive '{Archive}' not found for job {JobId}; skipping layer.", fullPath, jobId);
continue;
}
archiveLayers.Add(new LayeredRootFileSystem.LayerArchive(TryDeriveDigest(fullPath) ?? string.Empty, fullPath));
}
try
{
if (archiveLayers.Count > 0)
{
return LayeredRootFileSystem.FromArchives(archiveLayers);
}
if (directoryLayers.Count > 0)
{
return LayeredRootFileSystem.FromDirectories(directoryLayers);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to construct layered root filesystem for job {JobId}; entry trace skipped.", jobId);
return null;
}
var rootFsPath = ResolvePath(metadata, _workerOptions.Analyzers.RootFilesystemMetadataKey, ScanMetadataKeys.RootFilesystemPath);
if (!string.IsNullOrWhiteSpace(rootFsPath) && Directory.Exists(rootFsPath))
{
try
{
return LayeredRootFileSystem.FromDirectories(new[]
{
new LayeredRootFileSystem.LayerDirectory(TryDeriveDigest(rootFsPath) ?? string.Empty, rootFsPath)
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create layered filesystem from root path '{RootPath}' for job {JobId}; entry trace skipped.", rootFsPath, jobId);
return null;
}
}
_logger.LogDebug("No EntryTrace layers or root filesystem metadata available for job {JobId}; skipping entry trace.", jobId);
return null;
}
private static string ResolveImageDigest(IReadOnlyDictionary<string, string> metadata, ScanJobContext context)
{
if (metadata.TryGetValue("image.digest", out var digest) && !string.IsNullOrWhiteSpace(digest))
{
return digest.Trim();
}
if (metadata.TryGetValue("imageDigest", out var altDigest) && !string.IsNullOrWhiteSpace(altDigest))
{
return altDigest.Trim();
}
return context.Lease.Metadata.TryGetValue("scanner.image.digest", out var scopedDigest) && !string.IsNullOrWhiteSpace(scopedDigest)
? scopedDigest.Trim()
: $"sha256:{context.JobId}";
}
private static IReadOnlyCollection<string> ResolveList(IReadOnlyDictionary<string, string> metadata, string key, string fallbackKey)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return SplitList(value);
}
if (!string.Equals(key, fallbackKey, StringComparison.Ordinal) &&
metadata.TryGetValue(fallbackKey, out var fallbackValue) &&
!string.IsNullOrWhiteSpace(fallbackValue))
{
return SplitList(fallbackValue);
}
return Array.Empty<string>();
}
private static string? ResolvePath(IReadOnlyDictionary<string, string> metadata, string key, string fallbackKey)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return NormalizePath(value);
}
if (!string.Equals(key, fallbackKey, StringComparison.Ordinal) &&
metadata.TryGetValue(fallbackKey, out var fallbackValue) &&
!string.IsNullOrWhiteSpace(fallbackValue))
{
return NormalizePath(fallbackValue);
}
return null;
}
private static IReadOnlyCollection<string> SplitList(string value)
{
var segments = value.Split(new[] { ';', ',', '\n', '\r', IOPath.PathSeparator }, StringSplitOptions.RemoveEmptyEntries);
return segments
.Select(segment => NormalizePath(segment))
.Where(segment => !string.IsNullOrWhiteSpace(segment))
.ToArray();
}
private static string NormalizePath(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) ? string.Empty : trimmed;
}
private static string? TryDeriveDigest(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var candidate = path.TrimEnd(IOPath.DirectorySeparatorChar, IOPath.AltDirectorySeparatorChar);
var name = IOPath.GetFileName(candidate);
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var normalized = name;
if (normalized.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[..^7];
}
else if (normalized.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[..^4];
}
else if (normalized.EndsWith(".tar", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[..^4];
}
if (normalized.Contains(':', StringComparison.Ordinal))
{
return normalized;
}
if (normalized.StartsWith("sha", StringComparison.OrdinalIgnoreCase))
{
return normalized.Contains('-')
? normalized.Replace('-', ':')
: $"sha256:{normalized}";
}
return null;
}
}

View File

@@ -0,0 +1,10 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IDelayScheduler
{
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IEntryTraceExecutionService
{
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IScanAnalyzerDispatcher
{
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
}
public sealed class NullScanAnalyzerDispatcher : IScanAnalyzerDispatcher
{
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IScanJobLease : IAsyncDisposable
{
string JobId { get; }
string ScanId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAtUtc { get; }
DateTimeOffset LeasedAtUtc { get; }
TimeSpan LeaseDuration { get; }
IReadOnlyDictionary<string, string> Metadata { get; }
ValueTask RenewAsync(CancellationToken cancellationToken);
ValueTask CompleteAsync(CancellationToken cancellationToken);
ValueTask AbandonAsync(string reason, CancellationToken cancellationToken);
ValueTask PoisonAsync(string reason, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IScanJobSource
{
Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IScanStageExecutor
{
string StageName { get; }
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,155 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class LeaseHeartbeatService
{
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
private readonly IDelayScheduler _delayScheduler;
private readonly ILogger<LeaseHeartbeatService> _logger;
public LeaseHeartbeatService(TimeProvider timeProvider, IDelayScheduler delayScheduler, IOptionsMonitor<ScannerWorkerOptions> options, ILogger<LeaseHeartbeatService> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_delayScheduler = delayScheduler ?? throw new ArgumentNullException(nameof(delayScheduler));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task RunAsync(IScanJobLease lease, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(lease);
await Task.Yield();
while (!cancellationToken.IsCancellationRequested)
{
var options = _options.CurrentValue;
var interval = ComputeInterval(options, lease);
var delay = ApplyJitter(interval, options.Queue);
try
{
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
if (await TryRenewAsync(options, lease, cancellationToken).ConfigureAwait(false))
{
continue;
}
_logger.LogError(
"Job {JobId} (scan {ScanId}) lease renewal exhausted retries; cancelling processing.",
lease.JobId,
lease.ScanId);
throw new InvalidOperationException("Lease renewal retries exhausted.");
}
}
private static TimeSpan ComputeInterval(ScannerWorkerOptions options, IScanJobLease lease)
{
var divisor = options.Queue.HeartbeatSafetyFactor <= 0 ? 3.0 : options.Queue.HeartbeatSafetyFactor;
var safetyFactor = Math.Max(3.0, divisor);
var recommended = TimeSpan.FromTicks((long)(lease.LeaseDuration.Ticks / safetyFactor));
if (recommended < options.Queue.MinHeartbeatInterval)
{
recommended = options.Queue.MinHeartbeatInterval;
}
else if (recommended > options.Queue.MaxHeartbeatInterval)
{
recommended = options.Queue.MaxHeartbeatInterval;
}
return recommended;
}
private static TimeSpan ApplyJitter(TimeSpan duration, ScannerWorkerOptions.QueueOptions queueOptions)
{
if (queueOptions.MaxHeartbeatJitterMilliseconds <= 0)
{
return duration;
}
var offsetMs = Random.Shared.NextDouble() * queueOptions.MaxHeartbeatJitterMilliseconds;
var adjusted = duration - TimeSpan.FromMilliseconds(offsetMs);
if (adjusted < queueOptions.MinHeartbeatInterval)
{
return queueOptions.MinHeartbeatInterval;
}
return adjusted > TimeSpan.Zero ? adjusted : queueOptions.MinHeartbeatInterval;
}
private async Task<bool> TryRenewAsync(ScannerWorkerOptions options, IScanJobLease lease, CancellationToken cancellationToken)
{
try
{
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Job {JobId} (scan {ScanId}) heartbeat failed; retrying.",
lease.JobId,
lease.ScanId);
}
foreach (var delay in options.Queue.NormalizedHeartbeatRetryDelays)
{
if (cancellationToken.IsCancellationRequested)
{
return false;
}
try
{
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
try
{
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Job {JobId} (scan {ScanId}) heartbeat retry failed; will retry after {Delay}.",
lease.JobId,
lease.ScanId,
delay);
}
}
return false;
}
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class NoOpStageExecutor : IScanStageExecutor
{
public NoOpStageExecutor(string stageName)
{
StageName = stageName ?? throw new ArgumentNullException(nameof(stageName));
}
public string StageName { get; }
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class NullScanJobSource : IScanJobSource
{
private readonly ILogger<NullScanJobSource> _logger;
private int _logged;
public NullScanJobSource(ILogger<NullScanJobSource> logger)
{
_logger = logger;
}
public Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _logged, 1) == 0)
{
_logger.LogWarning("No queue provider registered. Scanner worker will idle until a queue adapter is configured.");
}
return Task.FromResult<IScanJobLease?>(null);
}
}

View File

@@ -0,0 +1,49 @@
using System;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class PollDelayStrategy
{
private readonly ScannerWorkerOptions.PollingOptions _options;
private TimeSpan _currentDelay;
public PollDelayStrategy(ScannerWorkerOptions.PollingOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public TimeSpan NextDelay()
{
if (_currentDelay == TimeSpan.Zero)
{
_currentDelay = _options.InitialDelay;
return ApplyJitter(_currentDelay);
}
var doubled = _currentDelay + _currentDelay;
_currentDelay = doubled < _options.MaxDelay ? doubled : _options.MaxDelay;
return ApplyJitter(_currentDelay);
}
public void Reset() => _currentDelay = TimeSpan.Zero;
private TimeSpan ApplyJitter(TimeSpan duration)
{
if (_options.JitterRatio <= 0)
{
return duration;
}
var maxOffset = duration.TotalMilliseconds * _options.JitterRatio;
if (maxOffset <= 0)
{
return duration;
}
var offset = (Random.Shared.NextDouble() * 2.0 - 1.0) * maxOffset;
var adjustedMs = Math.Max(0, duration.TotalMilliseconds + offset);
return TimeSpan.FromMilliseconds(adjustedMs);
}
}

Some files were not shown because too many files have changed in this diff Show More