StellaOps Scanner
Verified against commit
942a18727c(2026-09-26). Re-verify with:grep -nE "^(app|apiGroup)\.Map" src/Scanner/StellaOps.Scanner.WebService/Program.csandgrep -n "^ scanner-" devops/release/bundle/docker-compose.yml.The estate-wide service, database, compose and bundle wiring is generated in the service inventory; this dossier describes the module and defers to that inventory for the service list.
Layer-by-layer container image analysis that produces deterministic SBOMs, per-layer fragments, diffs, reachability evidence and signed reports.
Scanner decomposes container images layer by layer, runs signed OS, language and native analyzers, and emits deterministic SBOMs, per-layer fragments, diffs, reachability evidence and DSSE-signed reports for Policy, Release Orchestrator, Findings and the Console. Advisory knowledge (matching, affected symbols, EPSS) comes from the Vulnerabilities hub over its owner API; Scanner computes no policy verdicts.
This page is the module map. The full contract is architecture.md; the route catalogue is endpoint-registration-matrix.md.
Deployables
| Deployable | Source | Role |
|---|---|---|
scanner-web | src/Scanner/StellaOps.Scanner.WebService | REST control plane (port 8444, alias scanner.stella-ops.local, Router microservice scanner): scan submission and status, exports, triage, reports, sources, settings, OCI referrer store (/v2/...), Doctor checks, sole migration authority for the scanner schema. |
scanner-worker | src/Scanner/StellaOps.Scanner.Worker | Queue consumer (scanner:jobs on Valkey) that pulls images, builds the filesystem, runs analyzers, and publishes SBOM referrers, reachability evidence and surface manifests. |
scanner-cache-init | worker image, one-shot | Creates and chowns the cache and surface volumes before both hosts start. |
Other projects at the module root are not deployables: StellaOps.Scanner.Sbomer.BuildXPlugin (BuildKit generator CLI, packaged through devops/release/components.json) and StellaOps.Scanner.Analyzers.Native (ELF/PE/Mach-O readers, a class library).
Persistence
- Database
stellaops_scanner, schemascanner, reached only throughSTELLAOPS_POSTGRES_SCANNER_CONNECTION(override keyPostgres:Scanner:ConnectionString); no shared-connection fallback. - Library
src/Scanner/__Libraries/StellaOps.Scanner.Persistence; forward-only embedded migrations001-010underMigrations/, applied byscanner-webat startup (AddScannerPersistence). The Worker shares the database without migration authority. - Object storage: SeaweedFS over S3 (
s3.stella-ops.local:8333), bucketsscanner-artifactsandsurface-cache. - Retention: daily cleanup and the
scanner.artifact_boms12-month partition window run insidescanner-web(retention classification, SBOM hot-lookup operations). - Doctor:
doctor.scanner.database.own-database-cutover,doctor.scanner.schema.table-census,doctor.scanner.storage.epss-mishomed-footprint,doctor.scanner.retention.partition-runway,doctor.scanner.retention.artifact-boms-window(src/Scanner/__Libraries/StellaOps.Scanner.Operations/Doctor/ScannerDoctorChecks.cs). - Database capacity:
doctor.scanner.capacity.budgetreads the leased owner-local receipt added by015_capacity_probe_receipt.sql. Monitoring and its limits distinguish required pause from actual writer-gate adoption.
Integrations & dependencies
- Vulnerabilities hub: advisory matching, affected symbols, EPSS (
Scanner:Worker:Reachability:HubAdvisoryBaseUrl,Epss__HubBaseAddress). - Policy: report verdicts through
IPolicyOwnerClient(Policy’s Scanner owner API). - Integrations: SCM/registry credential resolution and the registry image-discovery stream that triggers scans.
- Release Orchestrator: consumes
GET /api/v1/sbom-readinessand the digest-detail route; receives release evidence from the Worker. - OfflineKit: owns carrier import; calls Scanner’s bounded artifact-BOM exchange routes (
/api/internal/v1/offlinekit/artifact-boms). - Evidence / Attestor, Signer: scan-result DSSE submission and PoE signing.
- Findings, Integrations, Policy: Findings and Integrations consume the
scanner.scan.completedtenant feed into their own databases; Policy readsGET /api/v1/scans/by-digest/{imageDigest}/detail(scan-completed-contract.md). - Console: the contributor scan history (My services and the Developer dashboard card) reads the same digest-detail route once per image of each owned service’s latest release, with the session’s
scanner:read. The route’s captured contract is the scopedopenapi/scan-digest-detail.v1.json(2026-10-11); Scanner publishes no fullopenapi/v1.json.
Recent changes (2026-09-24…26)
- Docker Hub references (DAY-3):
docker.io/...references are pulled throughregistry-1.docker.io, with thelibrary/namespace for single-segment names;RegistryApiReferenceinsrc/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/is shared by the Worker andstella sbom generate,stella sbom inspectandstella sbom attach.RegistryHostAliasesstill win, and a bare reference withoutDefaultRegistrystill fails closed. - SBOM export of worker scans (DAY-3):
GET /api/v1/scans/{scanId}/exports/sbomreads the CycloneDX document the Worker persisted in the internal OCI referrer store throughIScanSbomDocumentReader;spdx2(default) renders SPDX 2.3 andspdx3renders SPDX 3.0.1. - Per-layer SBOM routes for worker scans (DEF-11):
GET /scans/{scanId}/layers,/layers/{layerDigest}/sbom,/composition-recipeandPOST /composition-recipe/verifyread thelayer.fragmentsartifact of the Worker’s surface manifest throughSurfaceManifestLayerFragmentReaderand render withLayerSbomComposer. - Retention (RFC-1, RFC-19): all Scanner tables carry a retention class (migrations
005-010);scanner.prune_observations_and_responses()runs daily;ScannerPartitionRunwayServicedropsscanner.artifact_bomspartitions older than 12 months.
See architecture.mdsections 3.2, 4 and 5.2.1 for the details.
Related resources
Operations
- Analyzers operations guide
- Analyzers Grafana dashboard
- Retention classification
- SBOM hot-lookup operations
- CAS capacity guard
- RustFS migration playbook
- Entrypoint resolution
- Secret leak detection
- Proof-of-Entitlement dev harness
- DSSE / Rekor operator guide
- Field engagement notes
Contracts
scan.completedproducer contract (DC-15) - the event and digest-scoped read shapes that replace cross-databasescanner.*reads.- Endpoint registration matrix
- Observability
Analyzers & evidence
- Node analyzer
- Go analyzer
- OS analyzers - evidence model
- Artifact association graph
- Deterministic SBOM composition
- SBOM attestation hot-lookup profile
Design
Benchmarks
- macOS deep dive
- Windows/macOS demand
- Windows/macOS interview template
Gating Explainability (Quiet-by-Design Triage)
The Scanner WebService exposes gating explainability through the triage APIs to support the “Quiet-by-Design” UX pattern where noise is gated at the source and proof is surfaced with one click.
Gating Reasons
Findings can be hidden by default based on the GatingReason enum (Contracts/GatingContracts.cs). Enum members and their numeric values:
| Enum member (value) | Description |
|---|---|
None (0) | Not gated; visible in the default view |
Unreachable (1) | Vulnerable code not reachable from any application entrypoint |
PolicyDismissed (2) | Policy rule dismissed/waived/tolerated this finding |
Backported (3) | Patched via distro backport (version comparison confirms fixed) |
VexNotAffected (4) | VEX statement declares not_affected with sufficient trust |
Superseded (5) | Superseded by a newer advisory or CVE |
UserMuted (6) | Explicitly muted by a user decision |
Key DTOs
Source: Contracts/GatingContracts.cs and Contracts/TriageContracts.cs.
FindingGatingStatusDto- CarriesGatingReason,IsHiddenByDefault,SubgraphId,DeltasId,GatingExplanation, andWouldShowIf(criteria that would un-gate the finding). Computed byGatingReasonService.FindingTriageStatusWithGatingDto- Composes the baseFindingTriageStatusDto(BaseStatus) with an optionalGating(FindingGatingStatusDto) andVexTrust(TriageVexTrustStatusDto).FindingTriageStatusDto.Evidence[*].Typeuses the persisted Scanner triage wire vocabulary (SBOM_SLICE,VEX_DOC,PROVENANCE,CALLSTACK_SLICE,REACHABILITY_PROOF,REPLAY_MANIFEST,POLICY,SCAN_LOG,OTHER).TriageStatusServicemaps these values exhaustively and fails on unknown enum members rather than leaking CLR names.TriageVexTrustStatusDto- Wraps the baseTriageVexStatusDto(VexStatus) and addsTrustScore,PolicyTrustThreshold,MeetsPolicyThreshold, andTrustBreakdown(VexTrustBreakdownDto).GatedBucketsSummaryDto- Counts of hidden findings per gating reason (UnreachableCount,PolicyDismissedCount,BackportedCount,VexNotAffectedCount,SupersededCount,UserMutedCount) plus a computedTotalHiddenCount.BulkTriageQueryWithGatingResponseDto- IncludesGatedBuckets(GatedBucketsSummaryDto),TotalCount,VisibleCount,NextCursor, andSummary.
VEX Trust Scoring
VEX statements are evaluated against a policy trust threshold (default 0.7 — GatingReasonService.DefaultPolicyTrustThreshold). The composite trust score is a weighted combination of four factors (VexTrustBreakdownDto):
- Issuer trust (weight 0.4): issuer reputation (e.g.
nvd= 1.0, RedHat/Canonical/Debian = 0.95). - Recency trust (weight 0.2): age of the statement (≤7 days = 1.0, decaying to 0.3 for >1 year).
- Justification trust (weight 0.2): length/detail of the justification text.
- Evidence trust (weight 0.2): presence of DSSE envelope, signature reference, and source reference.
When a not_affected VEX record scores >= DefaultPolicyTrustThreshold, the finding is gated with GatingReason.VexNotAffected.
Unified Evidence Endpoint
GET /api/v1/triage/findings/{findingId}/evidence (route base api/v1/triage, Controllers/TriageController.cs) returns all evidence tabs in one call. Each tab can be toggled via include* query parameters (includeSbom, includeReachability, includeVex, includeAttestations, includeDeltas, includePolicy, includeReplayCommand; all default true). The response is ETag-cached (Cache-Control: private, max-age=300) and honours If-None-Match for 304 Not Modified. Tabs returned:
- SBOM reference and component metadata
- Reachability subgraph with call paths
- VEX claims with trust scores
- Attestation summaries
- Delta comparison
- Policy evaluation results
- Manifest hashes for verification
- Replay command for deterministic reproduction
The canonical findingId is a bare UUID from GET /api/v1/triage/inbox?artifactDigest=..., specifically a value returned in paths[].findingIds[]. The artifact digest is a tenant-scoped multi-row filter; it is not a finding identity. Findings-service matcher tokens are a separate identity namespace and must not be used on Scanner Triage routes.
Real responses include the linked stored scan record and stored finding lifecycle timestamps. A real finding with missing child evidence returns 200 with availability.status=partial and sorted availability.missing[] entries that name the stable absence code, producer, and recovery action. Scanner does not manufacture missing hashes, SBOM/policy versions, attestation verification, or replay inputs: unknown values remain null or unknown. Equal-timestamp child records are selected deterministically using the record UUID as the tie-break. A missing or cross-tenant finding returns a non-echoing typed 404 with producer and inbox-recovery metadata.
Evidence Bundle Export
GET /api/v1/triage/findings/{findingId}/evidence/export?format=zip (also accepts format=tar.gz) returns a downloadable archive built by EvidenceBundleExporter. The response carries an X-Archive-Digest: sha256:... header for verification. A single-finding bundle contains (file names emitted by EvidenceBundleExporter.PrepareEvidenceFilesAsync):
manifest.json- Bundle manifest (ArchiveManifestDto) with per-file SHA-256 hashessbom.cdx.json- CycloneDX SBOM slice (when SBOM evidence is present)reachability.json- Reachability evidence (when present)vex/<source>.json- VEX claims grouped by sourceattestations/<predicate>.dsse.json- DSSE attestation envelopespolicy/- Policy evaluation evidencereplay.sh/replay.ps1- Replay scripts
The run-level export (ExportRunAsync) instead nests each finding under findings/<findingId>/ beneath a top-level MANIFEST.json and README.md.
Replay Command Generation
The IReplayCommandService (ReplayCommandService) generates copy-ready CLI commands. The default binary is stellaops (overridable via scanner:replayCommands:binary). The full command form is:
stellaops replay --target <component-purl> --cve <CVE-ID> --feed-snapshot <hash> --policy-hash <hash> --verify
When a knowledge snapshot is available, a short form is also emitted (stellaops replay --target ... --cve ... --snapshot <snapshotId> --verify). For offline replay (IncludeOffline): stellaops replay --target ... --cve ... --bundle ./evidence-<scanId>-<findingId>.tar.gz --offline --verify.
The response (ReplayCommandResponseDto) also carries the ExpectedVerdictHash so a replayed verdict can be byte-compared against the recorded one.
The gated-bucket summary, VEX trust display, replay command and gating explainer are rendered by the Console (src/Web/); the authoritative values are the DTOs and constants named above.
