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