Scanner Architecture

Stella Ops Scanner subsystem (baseline 2025Q4). Aligned with Epic 6 – Vulnerability Explorer and Epic 10 – Export Center.

Scope. Implementation-ready architecture for the Scanner subsystem: WebService, Workers, analyzers, SBOM assembly (inventory & usage), per-layer caching, three-way diffs, artifact catalog (RustFS default + PostgreSQL, S3-compatible fallback), attestation hand-off, and scale/security posture. This document is the contract between the scanning plane and everything else (Policy, Excititor, Concelier, UI, CLI). Related: docs/modules/scanner/operations/ai-code-guard.md Storage profile: docs/modules/scanner/sbom-attestation-hot-lookup-profile.md


0) Mission & boundaries

Mission. Produce deterministic, explainable SBOMs and diffs for container images and filesystems, quickly and repeatedly, without guessing. Emit two views: Inventory (everything present) and Usage (entrypoint closure + actually linked libs). Attach attestations through Signer→Attestor→Rekor v2.

Boundaries.

Decision-attestation signature boundary (verified 2026-07-17). Scanner-owned policy-decision and human-approval statements are canonicalized and signed as compact DSSE envelopes through the same operator-configured ScanAttestationSigningKeyProvider used by scan attestations. Approval reads expose the envelope as dsse_envelope. AttestationChainVerifier requires a valid envelope over the exact canonical statement; missing envelopes, unknown keys, payload changes, and signature changes fail closed as InvalidSignature. If signing is enabled but the key is unavailable, decision recording remains available but the result is stored unsigned and cannot satisfy chain verification. This bounded lane does not implement the older cross-module Attestor proof-spine aggregate.

Scan-result attestation anchoring and fail-visible contract (SPRINT_20260721_003 / SPRINT_20260721_004). When scanner:ScanAttestation is enabled, ScanAttestationService signs the report DSSE and submits it to the internal Attestor transparency log at POST /api/v1/rekor/entries. This is an internal tokenless bypass submission (authorized by the Attestor’s BypassNetworks, no bearer token), so Scanner forwards the scan tenant in X-StellaOps-TenantId; the Attestor hard-requires that tenant.

Submission failures are classified at the HTTP boundary, never by parsing an exception message:

Attestor outcomeFailure classRetryableReport status
HTTP 4xxvalidation_rejectednorejected
HTTP 5xxbackend_unavailableyesself-signed
Network transport failurebackend_unavailableyesself-signed
Timeout (when the caller did not cancel)backend_unavailableyesself-signed
Caller cancellationn/an/acancellation propagates
Successful entry with id and log indexn/an/aattested

Validation rejection is not the backend-outage fail-open path. Scanner retains the honestly signed DSSE for diagnosis, returns wire status rejected, and preserves only the Attestor ProblemDetails title when it is printable ASCII. The raw response body is never copied into a report or log. Genuine backend unavailability retains the D1 scan-availability posture (self-signed), but it is no longer silent.

Both failure outcomes add one item to attestation.evidenceGaps with the stable type transparency_anchor_failed. The additive item contains code, failureClass, nullable httpStatus, retryable, and the canonical 4C fields: consequence, safe cause, cure, and custodian. Intentional SubmitToAttestor=false self-signing does not claim an anchoring failure and therefore emits no gap.

Report creation attaches the resulting attestation DTO to the latest tenant-scoped scan with the same image digest. The attachment is persisted as scanner.scan_runtime_state.report_attestation_json and returned additively by GET /api/v1/scans/{scanId}. The Console’s real Scan Detail route consumes that field. A transparency_anchor_failed item renders through the canonical expanded verdict component with the server-owned consequence, cause, cure, and custodian; the browser does not infer or replace any of those values.

Mounted plugin runtime (updated 2026-06-12). Scanner executable analyzer bundles are mounted read-only from devops/plugins/scanner/<profile>/<plugin-id>/ to /app/plugins/scanner/<profile>/<plugin-id>/. The base compose points Scanner Web at SCANNER_SCANNER__PLUGINS__BASEDIRECTORY=/app/plugins, SCANNER_SCANNER__PLUGINS__DIRECTORY=/app/plugins/scanner/base, and SCANNER_SCANNER__PLUGINS__TRUSTSTOREDIRECTORY=/app/trust-roots/plugins/scanner so the WebService verifies the same mounted base bundle directory it reports through /internal/plugins/{status,probe}. Scanner Worker points both ScannerAnalyzerHost__PluginDirectory and ScannerOsAnalyzerHost__PluginDirectory at /app/plugins/scanner/base, mounts config at /app/etc/plugins/scanner, trust roots at /app/trust-roots/plugins/scanner, and writes probe output under /var/lib/stellaops/plugin-scratch/scanner. Both hosts run the same signed manifest, trust-root, and assembly-hash admission before inspecting the entry point contract. A verified bundle owned by the sibling host is skipped for that host to activate; a verified type implementing neither known Scanner analyzer contract is rejected fail-closed. This permits the base root to carry five language and three OS bundles without activation errors or weakening trust. Analyzer policy, signatures, and rule catalogs remain declarative data unless an analyzer bundle is admitted through the signed plugin path. The recommended compose overlay keeps Scanner on the base analyzer set. The optional scanner-full-analyzers overlay mounts the complete signed language bundle at the canonical runtime root /app/plugins/scanner/analyzers and points ScannerAnalyzerHost__PluginDirectory there; it also mounts /app/plugins/scanner/base for the signed base OS analyzers used by ScannerOsAnalyzerHost. The optional Scanner OS overlay changes only ScannerOsAnalyzerHost__PluginDirectory to /app/plugins/scanner/analyzers/os; it does not redirect the language host. The harness overlay points at the signed Hello-Lang sample analyzer (stellaops.scanner.plugin.hello) so dummy probe evidence uses the same IScannerLanguageAnalyzerPlugin contract as third-party analyzers without adding production analyzer payloads to the image.

Secrets bundle runtime surface (updated 2026-06-06). Scanner WebService and Worker no longer reference the executable StellaOps.Scanner.Analyzers.Secrets implementation project. Bundle manifest, verification, store, DSSE envelope, change-notification contracts, and Worker-facing secrets evidence DTOs live in StellaOps.Scanner.Surface.Secrets. The Worker secrets stage consumes ISecretsAnalyzerRuntime; the default implementation loads the legacy secrets analyzer assembly from the mounted bundle path configured under Scanner:Worker:Secrets:{PluginDirectory,PluginAssemblyPath,PluginAssemblyName,PluginTypeName}. This removes image/source coupling, but it is still a transitional runtime adapter: final Scanner acceptance still requires a signed manifest-bound secrets plugin entry point, read-only compose mount, trust-root admission, and probe proof.

SBOM dependency reachability inference uses dependency graphs to reduce false positives and apply reachability-aware severity adjustments. See src/Scanner/docs/sbom-reachability-filtering.md for policy configuration and reporting expectations.

Source/build-stage call-path evidence is prepared before dependency reachability when scanner.source.path, scanner.build_stage.rootfs.path, or scanner.attached_sbom.path metadata is present. Static runtime images that no longer contain manifests or source trees must provide a build-stage filesystem root or attached CI SBOM to produce useful SBOM and call-path evidence. The detailed input contract, emitted metadata keys, and coverage limits live in callpaths-build-stage-sbom.md. When Release Orchestrator emits ComponentVersionScmInfoAvailable after a digest-pinned component version gains both sourceRepoUrl and sourceCommitSha, Scanner.Worker consumes that domain event through the ScmInfoAvailableTrigger bridge. The bridge creates a deterministic source-backed scan id from tenant, component, version, digest, repository URL, and commit SHA; writes a Pending row to scanner.scan_runtime_state; and enqueues the scan with scanner.source.repository_url, scanner.source.commit, and scanner.source.reference attributes. Duplicate events for the same tuple are idempotent: pending scans are re-enqueued, while completed scans are not. When the event includes imageReference, the bridge stores it as the scan target reference, serializes it into the queue payload, and emits the image.reference queue attribute. Release Orchestrator supplies this as a digest-pinned imageName@sha256:<digest> reference on build-time co-production events so the Scanner worker can resolve the OCI repository for subject pulls and referrer publication; legacy events without the field still fall back to digest-only metadata. Source checkout treats archive downloads as a hard prerequisite for these scans. For HTTP(S) SCM repository URLs, Scanner.Worker first tries deterministic archive candidates anonymously so public repositories and local archive fixtures do not depend on integrations-web. If anonymous archive download fails, the worker asks integrations-web for active IntegrationType.Scm rows, matches the source repository host, resolves the credential through the trusted /resolve-credentials path, unseals the mandatory response envelope in memory, and applies the provider-appropriate archive credential header (PRIVATE-TOKEN for GitLab, token auth for Gitea, bearer/basic otherwise). When no SCM integration matches, the original anonymous archive failure is reported. When an active SCM integration matches but its credential cannot be resolved or the shared response-domain KEK is absent/mismatched, the worker fails closed instead of falling back to anonymous or accepting plaintext transport. For build-time co-production reruns where Release Orchestrator supplies an already-staged file:// source context (for example a mounted /var/lib/stella-agent/... build context), checkout bypasses integrations-web and materializes the local directory or zip archive into the same per-scan workspace metadata used by the archive path. If a private SCM host follows redirects to an HTML login page or otherwise returns a non-zip response, the scan fails with the candidate URL, final URL, content type, and a short sanitized prefix instead of surfacing a generic ZIP extraction error. Operators must configure an authenticated source integration or direct archive endpoint before private-repository reachability can resolve. Operators can disable or tune the bridge under Scanner:Worker:ScmInfoAvailableTrigger (Enabled, PollInterval, BatchSize, StartAfterEventId).

Registry auto-scan trigger (Sprint 20260607_001, AUTO-002/003/006). The sibling RegistryImageScanTrigger + RegistryImageDiscoveredBridgeService close the operator-reported gap “registry added but its images are never scanned ⇒ SBOM-readiness absent ⇒ deploy gate blocks”. When a registry integration becomes Active, integrations-web enumerates its repositories/tags, resolves each tag to its image digest, and appends a row to integrations.registry_image_discoveries (an append-only outbox; see the Integrations dossier). The Scanner.Worker bridge polls that outbox cross-schema — exactly as the SCM bridge polls release_orchestrator.domain_events — and routes each (tenant, registryHost, repo, tag, digest) into RegistryImageScanTrigger. The trigger:

Tune under Scanner:Worker:RegistryImageDiscoveredTrigger (Enabled, PollInterval, BatchSize, StartAfterEventId, FailedRetryMaxAttempts, FailedRetryMinInterval).

Build-time co-production (Sprint 20260604.017, ADR-024) reuses this same ScmInfoAvailableTrigger for images that Stella both builds and pushes itself via the EPF build.docker plugin. There is no separate scanner path for Stella-built images: the orchestrator publishes the identical ComponentVersionScmInfoAvailable event (keyed to the freshly-pushed digest) and this bridge submits the identical source-derived scan. The only difference versus a post-hoc, externally-built image is the reachability referrer’s source provenance annotation:

When a submitted image reference uses an operator-visible registry address that is not reachable from the Scanner container, Scanner:Worker:Pipeline:RegistryHostAliases maps that exact authority (host and port) to the container-network authority. The mapping is applied before the primary pull-layers manifest inspection and blob downloads, as well as the SCM/referrer paths, so scan readiness and metadata attach operate on the same digest. Alias matching is authority-exact: an alias for 127.1.1.5:80 does not implicitly rewrite another port on the same host.

Image originReachability sourcePath
Built + pushed by Stella, agent-side producer enabledbuild-contextcomputed from the live build context on the agent (Phase 2)
Built + pushed by Stella, Phase 1 onlyscm-fetchcentral source-derived scan via this bridge
Built elsewhere, SCM info availablescm-fetchcentral source-derived scan via this bridge
Built elsewhere, rootfs-only scanrootfsrootfs call-graph extraction

All four land on the same image subject through the same dual-write OCI referrer sink (ReachabilityReferrerSerializer

Referrers fallback-tag maintenance (registries without the Referrers API)

The dual-write push attaches every referrer (SBOM, reachability, verdict) by setting the OCI subject field on the referrer manifest. A registry that implements the OCI 1.1 Referrers API indexes that subject itself and echoes back the OCI-Subject: <subjectDigest> response header on the PUT. Some on-prem registries — notably GitLab CE’s container registry (the customer target, registry.example.com) — do not implement it: /v2/.../referrers/<digest> returns 404 and the PUT carries no OCI-Subject header. Without remediation the attached referrer is undiscoverable there.

OciArtifactPusher (Sprint 20260605.009, ADR-024 amendment) closes this per OCI dist-spec 1.1: when a referrer PUT succeeds and no OCI-Subject header comes back, it maintains the fallback tag sha256-<subjectDigestHex> whose content is an OCI image index enumerating the referrer descriptors for that subject. The pusher GETs the current index (treating 404 as empty), merges the new descriptor ({mediaType, artifactType, digest, size, annotations}) deduped by digest, sorts the descriptors by digest (ordinal) for determinism, and PUTs the index back as application/vnd.oci.image.index.v1+json. The index is serialized with the same JsonSerializerOptions/ICryptoHash/HashPurpose.Interop path as referrer manifests, so an identical set of referrers yields a byte-identical index. The maintenance runs for both the live push (PushAsync) and the durable-retry replay (PushPrebuiltManifestAsync), so a drained publish_failed referrer becomes discoverable too. A fallback-tag failure surfaces as OciRegistryException (never silently swallowed) and only runs when a subject is present. On a Referrers-API registry the header is present and the fallback tag is not written (the registry owns discovery). oras discover-style tooling reads the fallback tag transparently.

How a referrer manifest is addressed (digest-addressed, untagged)

A referrer is discovered by digest — via the Referrers API or the sha256-<subject> fallback index, both of which list referrers by their manifest digest, never by a tag. So content-derived evidence referrers (SBOM, reachability) are pushed by digest (PUT /v2/<repo>/manifests/sha256:<digest>, untagged) by setting OciArtifactPushRequest.DigestAddressed = true. Tagging them would leave sbom-cdx-<hash> / reach-cg-<hex> entries in tags/list that masquerade as images; digest-addressing keeps the tag namespace clean.

Idempotency without a tag relies on determinism: a digest-addressed referrer manifest omits the wall-clock org.opencontainers.image.created annotation, so re-attaching identical evidence yields the same manifest digest. SkipIfTagExists then HEADs that digest, finds it, and skips — and the fallback index dedupes by digest — so a re-scan/re-run never accumulates duplicate referrers. (Verified end-to-end against registry:2, registry:3, and zot: re-attach yields exactly one referrer, no sbom-cdx-* tag in tags/list.)

Verdict attestations are the exception — they keep a stable verdict-<idempotencyKey> tag (DigestAddressed left false) because their idempotency key can be caller-supplied and decoupled from the manifest bytes, which digest- addressing cannot express. They are still read by digest.

How the metadata lands, per case:

registry casereferrer manifest storeddiscovery mechanismextra entries in tags/list
Native Referrers API (zot, Harbor, ACR, ECR, GHCR…)by digest (untagged)server-side GET /v2/<repo>/referrers/<subject>none — only the image tag
No Referrers API (GitLab CE / registry.example.com; stock registry:2/registry:3)by digest (untagged)client-maintained sha256-<subject> OCI image-index tagone sha256-<subject> index tag per attached image
Verdict attestation (either registry)under verdict-<idempotencyKey> tagReferrers API / fallback index (by digest)one verdict-<key> tag

In all cases the SBOM/reachability payload blobs are ordinary content-addressed OCI blobs pushed to the same repository — every registry stores them — so the absence of the Referrers API never makes metadata unpublishable, only changes how it is discovered.

Ingesting referrers from the CLI/CI, and retention (sprint 20260622_001)

The internal OCI metadata registry (scanner.oci_referrers + oci_blobs + object store, read at /v2/{repo}/referrers|manifests|blobs/{digest} — see ADR-024) is the system of record. Two pieces make it first-class even when evidence is produced outside the worker:

The release gate reads this store first and, only when it has no attachment for an image (ExternalRegistryFallback, default on), falls back to reading the deployment registry’s referrers via OciReferrerReadClient — internal-first, then the deployment registry. The fallback is fail-safe: it is never consulted for a failed/DENY verdict and can only upgrade WAITING→PASS. (ReleaseOrchestrator SbomReadinessGateEvaluator; see ADR-024.)

Externally-built images: on-demand attach + CI_COMMIT_SHA anchor

Externally-built images (e.g. GitLab buildx with --provenance=false) are not driven by an orchestrator ComponentVersionScmInfoAvailable event and carry no standard org.opencontainers.image.source/.revision labels — only a custom CI label such as CI_COMMIT_SHA. Two Scanner pieces (Sprint 20260605.009) bridge this:

GitLab Container Registry onboarding, Vault-backed registry credentials, and the write-probe prerequisites for GitLab-CE-style registries are covered in the GitLab Container Registry onboarding runbook.

Build-time attach (stella sbom attach)

The on-demand handler above attaches referrers after an image lands (post-hoc scan). The complementary build-time path attaches them inside the CI build, right after the push, so the readiness gate flips green immediately with no separate scan round-trip. The stella sbom attach CLI verb (Sprint 20260608.001; supersedes the retired standalone stella-oci-attach tool from Sprint 20260607.017, SRD-BLD-001) wraps the same CycloneDxComposer and OciArtifactPusher (so it inherits the fallback-tag scheme verbatim — required for the GitLab CE customer registry). Given (image reference, pushed digest, registry creds via env) its core BuildTimeOciAttachService composes a genuine CycloneDX SBOM — generating it in-process from Stella’s own analyzers with --generate, re-composing a supplied CycloneDX SBOM when passed via --sbom (an upstream docker buildx --sbom document works the same way), else a metadata+layer SBOM keyed off the real inspected layer digests — and attaches it (plus an optional reachability call-graph referrer) to the digest. Deep per-package inventory remains the scanner-worker pipeline’s job; the build-time path’s contract is compose + attach at push time. Credentials are read from environment variables only (never args/files) so a PAT cannot leak into CI logs. Wiring, PAT scopes (read_registry+write_registry), the Vault reference (secret/app/gitlab-pat), and the component-SBOM-by-default / reachability-when-available boundary are documented in the build-time attach runbook with a ready-to-apply GitLab-CI job snippet shipped under devops/oci-attach/ (internal).

Internal upload (--stella-uri, sprint 20260622_001). By default the attach pushes referrers to the image’s external deployment registry. Passing --stella-uri <scanner> (or STELLAOPS_STELLA_URISTELLAOPS_BACKEND_URL) additionally uploads the same composed referrers to Stella’s internal metadata store via POST /api/v1/oci-referrers when a backend token is resolvable — so the internal store becomes the system of record even for images that live only in GitLab CR. It is purely additive: the external push is unchanged, and a failed internal upload surfaces (non-zero) without blocking a successful external attach. (See “Ingesting referrers from the CLI/CI, and retention” above and ADR-024.)

Self-describing referrer payloads + reading them back (stella sbom inspect)

Every Stella referrer is self-describing (Sprint 20260610.001, INSP-001): the referrer manifest carries org.stellaops.payload.* annotations so a reader can pick the right decoder from the manifest annotations alone — without fetching the layer-0 blob — and bail gracefully on anything it does not understand (the forward-compat contract). The annotations propagate onto the fallback-tag index descriptor (which copies the manifest annotations), so they are visible at discovery time:

AnnotationValuesMeaning
org.stellaops.payload.encodinggzip | identityHow the layer-0 bytes are wrapped.
org.stellaops.payload.formatreachability-evidence-set | callgraph-compact-indexed | cyclonedxThe logical payload format.
org.stellaops.payload.formatVersionintegerThe named format’s version (bump on any breaking wire change).

The DEFAULT reachability referrer is the pruned vuln-keyed evidence set (Sprint 20260610.002 REFERRER, decision D10 of the reachability strategy §8): artifactType application/vnd.stellaops.reachability.evidence-set.v1+json, format=reachability-evidence-set, formatVersion=1, encoding=gzip. The payload (ReachabilityEvidenceSetPayload) carries, per evaluated (CVE, purl): the canonical public verdict object (§7.0 lattice, coverage statement embedded for not-observed) + the pruned entry→sink subgraph (compact-v2 conventions with node ids retained; bounded maxPaths ≤ 5, maxDepth ≤ 15) + the PathWitness DSSE envelopes

The legacy whole-graph compact-indexed + gzip payload (SerializeCompactGzip in ReachabilityCallGraphGenerator): edges reference nodes by integer index into the ordinal-sorted node table, per-node sha256 IDs are dropped, null fields omitted, then gzipped — typically 20–40× smaller, so a tens-of-MB graph uploads in a single chunk. It is DEPRECATED/TRANSITIONAL as a referrer — attached only behind the programmatic BuildTimeOciAttachRequest.AttachWholeGraphReferrer debug flag (deliberately not a CLI option), with the v2 artifactType application/vnd.stellaops.reachability.callgraph.v2+json and the three annotations above (encoding=gzip, format=callgraph-compact-indexed, formatVersion=2); it remains the canonical wire form for CAS storage of full graphs. The v1 constant and a verbatim-supplied --reachability <file> keep the v1 artifactType. The CycloneDX SBOM referrer carries encoding=identity, format=cyclonedx for symmetry.

stella sbom inspect is the READ-side sibling of stella sbom attach. Given an image reference (creds env-only, anonymous when absent) it:

  1. Resolves the subject digest (pinned digest, an explicit --digest, or a manifest HEAD), then lists referrers via OciReferrerReadClient — the native OCI 1.1 Referrers API first, falling back to the sha256-<hex> fallback-tag index on 404/400 (GitLab CE). A missing fallback tag is “no referrers”, not an error.
  2. Branches each referrer on its artifactType + payload annotations: a CycloneDX SBOM is parsed into a component summary (total + per-purl-type breakdown + top-N; optional --sbom-out); a reachability call-graph is decoded by CompactCallGraphReader (the exact inverse of SerializeCompactGzip — gunzip → expand integer indices back to method symbols) and written to --callgraph-out (default stella-callgraph.json); anything unknown / Bailed is listed-and-skipped and the command still exits 0. The new reachability-evidence-set referrer currently rides that bail path (listed with its format annotations, payload skipped); a dedicated inspect decoder over ReachabilityEvidenceReferrerSerializer.DeserializeGzip is CLI follow-up work.
stella sbom inspect --image registry.example.com/example-corp/app-backend/core@sha256:… \
  --callgraph-out callgraph.json --sbom-out sbom.json

The reader never mis-parses or crashes: an unknown encoding/format/formatVersion, a truncated/garbage blob, or a legacy verbose-JSON referrer all return a graceful skip with a reason. Bump formatVersion on any breaking change and the older reader will bail forward rather than guess.

Dependency-free manual fallback. Without the CLI, the same payload is recoverable with oras + gunzip: read the fallback-tag index, pick the reachability referrer descriptor, fetch its layer-0 blob, and gunzip it to the compact-indexed JSON:

oras manifest fetch <registry>/<repo>:sha256-<subjectHex> | jq '.manifests'   # find the referrer digest
oras blob fetch --output - <registry>/<repo>@<reachabilityLayerDigest> | gunzip > callgraph-compact.json

ProofSpine output is the verifiable decision chain Scanner exposes via /api/v1/spines/{spineId}. Because ProofSpine is a verification surface, it is paired with a defensive input validator (ProofSegmentValidator) that converts tampered or malformed JSON into structured InvalidProofResult(reasonCode, reason) instead of throwing. See proofspine.mdfor the validator contract and reason-code reference, and observability.mdfor the scanner_proofspine_validation_failures_total metric.

Artifact association output binds scanner evidence to the source/build/image/SBOM lineage graph owned by SbomService. BuildX descriptors, source config metadata, scan jobs, detector reports, and secrets evidence now carry first-class subject fields so Console and CLI users can resolve a scan back to source snapshots, CI builds, OCI image digests, SBOM documents, detector reports, findings, and evidence bundles. See artifact-association-graph.mdfor the subject contract and operator workflow.

SBOM readiness is exposed as a thin control-plane contract for release gates: GET /api/v1/sbom-readiness?digest=<sha256>&tenant=<tenant> returns absent, pending, scanning, ready, or failed plus the latest scan id, timestamp, and failure reason when available. The resolver is durable because it reads both the PostgreSQL scanner.artifact_boms hot-lookup projection and the latest tenant-scoped scan_runtime_state row. artifact_boms.inserted_at is the only durable proof and timestamp of a successful scan; a runtime Succeeded status without that projection is not successful evidence.

The additive scanner.sbom-readiness.v2 fields separate the two timelines:

Legacy state, scanId, updatedAt, and failureReason keep their original semantics for Release Orchestrator and other v1 clients: a durable BOM still wins and returns state=ready, the BOM timestamp, and no scan id/failure reason. Consumers adopting attempt-versus-success truth must use the namespaced v2 fields rather than treating updatedAt as a successful-scan timestamp. The endpoint requires the existing scans:read policy; consumers must not scrape Scanner’s internal tables directly. See the SBOM readiness API contract for the wire shape and compatibility truth table.

Registry-source SBOM publishing is handled by Scanner.Worker after analyzers emit layer fragments. When a job already has a CycloneDX JSON SBOM path, the publish-sbom-referrer stage publishes those bytes. When registry-driven image scans do not carry an attached/build-stage SBOM path, the stage composes a CycloneDX inventory document from the analyzed layer fragments, writes it to the scan work root as inventory.cdx.json, records sbom.path/sbom.format, and then pushes the same bytes as an OCI artifact/referrer through ISbomOciPublisher. The artifact is attached to the immutable image digest and uses a stable sbom-cdx-<sbom sha256> tag plus org.stellaops.* annotations for SBOM digest, image digest, scan job id, and registry-source id. Missing fragments, unsupported formats, missing image digest, or registry push failures are fail-soft: the scan continues and the skip/failure is logged for operators.


1) Solution & project layout

The two runnable hosts sit directly under src/Scanner/ (StellaOps.Scanner.WebService/, StellaOps.Scanner.Worker/); the bulk of the code is shared libraries under src/Scanner/__Libraries/, tests under src/Scanner/__Tests/, and benchmarks under src/Scanner/__Benchmarks/. The Cartographer service also lives in-domain at src/Scanner/StellaOps.Scanner.Cartographer/ (see §1.0). Representative layout (not exhaustive — the actual __Libraries set is ~80 projects):

src/Scanner/
 ├─ StellaOps.Scanner.WebService/            # REST control plane, exports, manifests, proofs
 ├─ StellaOps.Scanner.Worker/                # queue consumer; executes the analyzer pipeline
 ├─ StellaOps.Scanner.Cartographer/          # Cartographer service (Scanner-owned)
 └─ __Libraries/
     ├─ StellaOps.Scanner.Core / .Contracts / .Models …  # DTOs, evidence, graph nodes, contracts
     ├─ StellaOps.Scanner.Storage (+ .Storage.Oci)        # PostgreSQL repos (auto-migrated); RustFS/object client; ILM/GC
     ├─ StellaOps.Scanner.Queue                           # transport abstraction (Redis/Valkey Streams, NATS)
     ├─ StellaOps.Scanner.Cache                           # layer cache; file CAS; bloom/bitmap indexes
     ├─ StellaOps.Scanner.EntryTrace                      # ENTRYPOINT/CMD → terminal program resolver (shell AST) + Semantic engine
     ├─ StellaOps.Scanner.Analyzers.OS{,.Apk,.Dpkg,.Rpm,.Pacman,.Portage,.Homebrew,
     │     .MacOsBundle,.Pkgutil,.Windows.Msi,.Windows.WinSxS,.Windows.Chocolatey}
     ├─ StellaOps.Scanner.Analyzers.Lang{,.Java,.Node,.Bun,.Deno,.Python,.Go,.DotNet,.Rust,
     │     .Ruby,.Php,.Ccpp,.Dart,.Elixir,.Swift} (+ .LangAdapter)
     ├─ StellaOps.Scanner.Analyzers.Native                # ELF + PE + Mach-O readers/parsers + hardening extractors
     ├─ StellaOps.Scanner.Analyzers.Secrets               # Secret leak detection
     ├─ StellaOps.Scanner.CallGraph                       # function/call-edge builder + CAS emitter
     ├─ StellaOps.Scanner.Emit (+ Composition CDX 1.7 JSON/Protobuf/XML) / StellaOps.Scanner.Sarif
     ├─ StellaOps.Scanner.Diff / .SmartDiff / .Delta / .ChangeTrace   # diff + delta-compare surfaces
     ├─ StellaOps.Scanner.Reachability / .ReachabilityDrift / .Gate / .Triage
     ├─ StellaOps.Scanner.ProofSpine / .ProofIntegration / .Manifest / .Evidence / .Explainability
     ├─ StellaOps.Scanner.ServiceSecurity / .CryptoAnalysis / .AiMlSecurity / .BuildProvenance / .PatchVerification
     ├─ StellaOps.Scanner.Surface{,.Env,.FS,.Secrets,.Validation} / .VulnSurfaces / .Runtime / .Registry / .Sources
     └─ StellaOps.Scanner.Plugin.{Contracts,Loader,SigningTool}
 # Tools/ (sibling root): StellaOps.Scanner.Sbomer.BuildXPlugin (BuildKit generator)
 #   (no StellaOps.Scanner.Sbomer.DockerImage project exists under src/)

Note (was stale): there is no StellaOps.Scanner.Symbols.Native project, and the call-graph library is StellaOps.Scanner.CallGraph (not .CallGraph.Native). PE and Mach-O native parsers are implemented today in StellaOps.Scanner.Analyzers.Native (PeReader/PeImportParser, MachOReader/MachOLoadCommandParser, plus PE/Mach-O hardening extractors), and Windows (MSI/WinSxS/Chocolatey) and macOS (Homebrew/bundle/pkgutil) OS analyzers exist — the “M2 / planned” notes for these elsewhere in this doc are historical. Runtime composition note (2026-06-06): scanner-worker no longer compiles the Native analyzer implementation project directly. The Worker-owned native discovery/execution path now consumes Build-ID index and section-hash abstractions from StellaOps.Scanner.Emit.Native; the current host section-hash extractor is a no-op until a signed native-stage bundle and probe contract are completed.

FuncProof composition status (verified 2026-07-19): FuncProof currently exists as uncomposed component libraries: model/builder, DSSE and transparency services, OCI publisher, PostgreSQL repository, and CycloneDX linker. Scanner Worker and WebService do not register or coordinate these components into a production scan pipeline, so native-image FuncProof generation/publishing is not a current runtime capability. Computed graph-purpose digests carry the active ICryptoHash prefix; standalone fallback computation uses SHA-256 and carries sha256: rather than a misleading blake3: label.

1.0 Cartographer Ownership (Sprint 201)

Per-analyzer notes (language analyzers):

Analyzer status (sprint 20260430.012 — pass-2 audit Group F closure):

AnalyzerStatusNotes
Java, .NET, Python, Go, Bun, Ruby, PhpProductionPre-existing.
Node.jsProductionF2 closed: RuntimeEvidenceLoader component-key fallback now derives a deterministic runtime-component:<sha256> key from the scrubbed evidence tuple instead of Guid.NewGuid(). Two runs over identical evidence produce byte-equal SBOM output.
DenoProductionF1a closed: analyzer now emits SBOM components from three streams — npm purls (pkg:npm/<name>@<version>) deduped by purl from compatibility.NpmResolutions; remote modules (type: deno:remote, key deno-remote::<sha256(specifier)>) for non-npm https:// module-graph nodes; bundle components (type: deno:bundle, key deno-bundle::<relativePath>) for each bundle observation. The observation::deno summary record is preserved unchanged.
RustProductionF1b closed: RustLanguageAnalyzer.DisplayName no longer carries the (preview) suffix; manifest.json analyzer status is production. New RustCargoTomlParser (Tomlyn-backed, BSD-2-Clause) reads [package], [dependencies], [dev-dependencies], [build-dependencies], [target.<cfg>.dependencies], [workspace] members, default-members, and [workspace.dependencies] (with dep.workspace = true resolution against the root). New RustWorkspaceResolver discovers manifests + expands crates/* glob members. RustAnalyzerCollector.CollectCargoManifests performs the declared-vs-installed merge: declared crates with a lock match append declared.versionSpec/declared.scope/declared.targetCfg/declared.manifestPaths metadata; declared-only crates emit a fresh component with metadata["declaredOnly"] = "true"; workspace-member crates with their own [package] emit a component carrying workspace.member = "true" and workspace.root metadata, plus workspace.defaultMember = "true" when the root’s default-members includes them.
C / C++ProductionConan, vcpkg, CMake cache, and pkg-config manifests emit deterministic pkg:conan, pkg:vcpkg, or pkg:generic purls. Optional local-only registry mirrors under __Datasets/ccpp/conan-center/packages.json and __Datasets/ccpp/vcpkg-index/packages.json enrich components with ccpp.registry.* metadata when an exact ecosystem/name/version match exists; absent or invalid mirrors degrade to purl-only output without network access.
SecretsProductionF3 closed: SecretFinding.Id fallback now derives a deterministic Guid via HMAC-SHA256(application-pepper, `ruleId

Cross-analyzer contract (identity safety, evidence locators, container layout):

Semantic entrypoint analysis (Sprint 0411):

Analyzer assemblies and buildx generators are packaged as restart-time plug-ins under plugins/scanner/** with manifests; services must restart to activate new plug-ins. Language analyzer bundles use one immediate child directory per analyzer under plugins/scanner/analyzers, each with a signed manifest.json, the DLL payload, transitive runtime files, and assembly.sha256 bound to the shipped DLL. Signed manifest metadata also binds the exact version and SHA-256 of every host-owned analyzer contract assembly. Worker verifies those stamps before Assembly.LoadFrom and aborts analyzer activation on any contract_assembly.* rejection, preventing cross-ALC API skew from degrading scans silently. Runtime production trust roots resolve to etc/scanner/trust-store/scanner-plugins; the committed src/__Tests/__Datasets/trust-store/scanner-plugins/dev key is only an image-staged compatibility fixture until mounted bundles land. Worker/WebService carry StellaOps.Scanner.Plugin.Contracts and StellaOps.Scanner.Plugin.Loader as runtime support; StellaOps.Scanner.Plugin.SigningTool is authoring/build tooling only. See plugin-signing.md.

1.3 Semantic Entrypoint Engine (Sprint 0411)

The Semantic Entrypoint Engine enriches scan results with application-level understanding:

Components:

Integration points:

CLI usage: stella scan --semantic <image> enables semantic analysis in output.

1.2 Native reachability upgrades (Nov 2026)

1.1 Queue backbone (Valkey Streams)

StellaOps.Scanner.Queue exposes a transport-agnostic contract (IScanQueue/IScanQueueLease) used by the WebService producer and Worker consumers.

Valkey Streams is the standard transport. Uses consumer groups, deterministic idempotency keys (scanner:jobs:idemp:*), and supports lease claim (XCLAIM), renewal, exponential-backoff retries, and a scanner:jobs:dead stream for exhausted attempts.

Metrics are emitted via Meter counters (scanner_queue_enqueued_total, scanner_queue_retry_total, scanner_queue_deadletter_total), and ScannerQueueHealthCheck pings the Valkey backend. Configuration is bound from scanner.queue:

scanner:
  queue:
    kind: valkey
    valkey:
      connectionString: "valkey://valkey:6379/0"
      streamName: "scanner:jobs"
    maxDeliveryAttempts: 5
    retryInitialBackoff: 00:00:05
    retryMaxBackoff: 00:02:00

The DI extension (AddScannerQueue) wires the transport.

Dispatch channel vs. read-model (Sprint 20260520_081, Path A)

The durable scan-job path uses two stores with a single writer per phase:

Config (bound from scanner:queue:*, env prefix SCANNER_): both scanner-web and scanner-worker set SCANNER_scanner__queue__redis__connectionString to the Valkey endpoint (cache.stella-ops.local:6379), kind=redis, streamName=scanner:jobs, consumerGroup=scanner-workers.

Registry-backed pull execution keeps the immutable digest supplied by the producer. When queue metadata carries a repository/tag in image.reference and a raw sha256: value in image.digest, pull-layers combines them as repository[:tag]@sha256:...; it must not silently inspect :latest. A full repository@digest remains valid on its own, while a raw digest without a repository is not pullable. Static pipeline credentials have precedence. When they are absent, the worker resolves the tenant Surface secret for component Scanner.Worker.Registry, type registry, default name, and selects the matching registry entry. Surface’s in-memory cache owns its stored handle and returns an independent duplicate to each caller, so disposing one consumer handle cannot poison later credential reads.

Runtime binding rule:

Runtime form-factor: two deployables


2) External dependencies


3) Contracts & data model

3.1 Evidence-first component model

Nodes

Edges (all carry Evidence)

Evidence

{ source: enum, locator: (path|offset|line), sha256?, method: enum, timestamp }

No confidences. Either a fact is proven with listed mechanisms, or it is not claimed.

3.1.1 Dependency scope model (declared → deployed → executed)

Every release decision answers three increasingly narrow questions about a component, in three tiers:

  1. found / declared — is the component present in the SBOM at all? (SBOM-presence; the inventory tier.)
  2. deployed — does the component actually ship at production runtime, or is it declared-but-not-deployed tooling (linters, bundlers, test frameworks)? (The scope tier — the middle tier this section defines.)
  3. executed / reachable — within what is deployed, is the vulnerable code actually reached? (The reachability tier; see reachability and §6 of the dependency-scope sprint.)

Scope is the middle tier: it sits between SBOM-presence (tier 1) and reachability (tier 3). Without it, Stella reports the entire declared dependency tree — dev + build + transitive tooling — on equal footing with what actually ships, which is correct for supply-chain breadth but buries the gate-relevant deployed findings. Scope partitions the tree without dropping anything.

Canonical scope vocabulary

One ecosystem-agnostic enum, StellaOps.Scanner.Core.Contracts.DependencyScope, is shared by analyzers, the composer, the offline matcher, and the CLI:

ScopeMeaningIn deployed-set?
RuntimeRequired at production runtime (“prod”).Yes
BundledVendored inside another package’s tarball; ships with its parent.Yes (iff parent deployed)
ProvidedSupplied by the runtime/container at execution (maven provided, servlet APIs).Yes
OptionalInstalled only if the platform allows; may be absent at runtime.Yes (fail-safe)
PeerExpected to be provided by the consumer; not vendored by this package.Yes (fail-safe)
BuildBuild-time tooling consumed during compile, not shipped (bundlers, transpilers, codegen).No
TestTest frameworks / fixtures only.No
DevDevelopment-only (linters, formatters, generators).No
UnknownCould not be determined (no graph; degraded lockfile; severed edge; 3rd-party SBOM).Yes (fail-safe)

deployed-set = {Runtime, Bundled, Provided, Optional, Peer, Unknown}; non-deployed = {Dev, Test, Build}. Unknown is load-bearing and sits on the deployed side: any edge/component that cannot resolve to a scope defaults to deployed-equivalent treatment, so a vulnerability is never hidden on missing data (DependencyScope.IsDeployed).

Declared vs effective scope. Declared scope is per-edge (A → B as written in a manifest); the same component may be Runtime from one parent and Dev from a root simultaneously. Effective scope is per-component and graph-wide: the most-deployed scope under which the component is reachable from any project root, after transitive propagation. The effective scope is the emitted/filtered value; declared scope is retained on the direct edges for auditability.

Effective-scope algorithm

Each analyzer adapter produces a DependencyScopeGraph (synthetic project roots carrying section scope, instance nodes, scoped edges, optional authoritative per-node hints, and a severed-node set) and an ICombineStrategy; the shared ScopeResolver (StellaOps.Scanner.Analyzers.Lang/Scope/ScopeResolver.cs) resolves it identically across ecosystems:

  1. Seed roots. The synthetic project root is Runtime; each root edge carries its section scope (dependencies=Runtime, devDependencies=Dev, optionalDependencies=Optional, peerDependencies=Peer).
  2. Monotone fixpoint relaxation. Propagate COMBINE(effective(parent), edgeScope) along edges; the effective scope of a node = the most-deployed over all inbound propagated edges. Scopes only ever move toward more deployed, so the relaxation terminates even with cycles (each node re-pushed at most one per lattice level). R1 (deployed dominates): a component reachable from both a runtime root and a dev root resolves to Runtime — prod presence is contagious, the inverse of the “transitive-via-dev” trap.
  3. Hint reconciliation (fill/confirm only). Authoritative per-node flags (e.g. npm dev:true) fill a node that the walk never reached, or refine within the same side of the cut; they never raise a node across the deployed/non-deployed boundary — the graph walk is authoritative for the cut.
  4. Peer reconciliation (R3). A peer node takes the most-deployed of its non-peer inbound edges; a pure-peer node floors at Peer (deployed-side).
  5. Severed-edge floor (R2). A node reachable only through an unresolvable link:/workspace:/peer path floors at Unknown (deployed) and is marked scope.resolution = partial-graph.
  6. Orphans → Unknown (deployed-side fail-safe).
  7. Collapse instances → (name, version): emitted scope = most-deployed across instances; out-edge targets resolved to component-keys, self-refs dropped, ordinal-sorted (deterministic, byte-identical re-runs).

COMBINE is a deployment-AND: B is deployed via edge A →[edge] B iff A is deployed and the edge is a deploy-edge. A runtime package’s dev/build/test deps are that package’s tooling and do not make them runtime; a dev package poisons its whole subtree to dev. The default strategy is the npm table; ecosystems with non-transitive scope semantics override ICombineStrategy— maven provided/test and rust dev/build deps do not propagate to children (NonTransitiveCombineStrategy).

CycloneDX / SPDX emission

Once an analyzer resolves scope and sets the LanguageComponentRecord fields (EffectiveScope, DeclaredScopeAtRoot, DependsOn, ScopeResolution), LanguageComponentMapper wires them into the SBOM automatically:

Declared-only prediction caveat

When the SBOM is the lockfile transcribed with no filesystem/install evidence (stellaops.lang.meta.declaredOnly=true), the deployed partition is a prediction (“would-be-deployed if you npm ci --omit=dev”), not an observation: if the image actually installs devDependencies (or COPYs node_modules wholesale) the declared-only findings ARE present. The CLI gate therefore warns and surfaces the excluded count on a declared-only SBOM rather than silently relaxing the gate. In a real layered-image scan, a Dev-declared component carrying runtime-layer install evidence is treated as deployed regardless of declared scope (R1 applied across the scope × install-evidence product).

3.2 Catalog schema (PostgreSQL)

Persistence lives in StellaOps.Scanner.Storage and is auto-migrated on startup via AddStartupMigrations<ScannerStorageOptions>(...) (per the repo-wide DB auto-migration rule). SQL migrations are embedded resources under Postgres/Migrations/ (compacted into 001_v1_scanner_storage_baseline.sql plus incremental files 002009; the pre-1.0 chain is archived under Migrations/_archived and excluded from embedding); tables use snake_case columns, not Mongo-style _id documents.

Core catalog tables (public schema):

The schema is much larger than the catalog. Notable durable tables (public + scanner schema): scan_runtime_state (status, entropy, replay, and latest report-attestation read-model), scan_manifest + proof_bundle + score_replay_history (manifest/proof/score-replay), scanner.scan_signed_sbom_materials (signed-SBOM material store), scanner.sbom_uploads + scanner.sbom_sources/sbom_source_runs (BYOS/SCM), scanner.scan_evidence_projections, proof_spines/proof_segments/proof_spine_history, call_graph_snapshots/reachability_results/reachability_stacks/reachability_stack_frames/reachability_drift_results, unknowns, scan_findings, the EPSS layer (epss_raw/epss_scores(partitioned)/epss_current/epss_changes/epss_signal), smart-diff (risk_state_snapshots/material_risk_changes/vex_candidates), binary_identity/binary_package_map/binary_vuln_assertion/binary_patch_verifications, scanner.witnesses/witness_verifications, secret_detection_settings/secret_exception_pattern, and classification_history (with the fn_drift_stats materialized view). Many scanner.* tables enable row-level security and carry a tenant_id. The Triage library ships its own schema under StellaOps.Scanner.Triage/Migrations/.

Reachability evidence projection

The Worker persists reachability_findings in scanner.scan_evidence_projections as the Findings-facing evidence boundary. Every projected row carries the exact vulnerabilityId and componentPurl, the public verdict state, the declared analysisMethod, and a deterministic pathSymbols witness when the analyzer proved a path. Static call-graph results preserve the analyzer’s method and actual call path; entrypoint-only evidence declares component-entrypoint-usage; dependency-only evidence declares sbom-only. Scanner never upgrades missing method/path data into inferred proof.

The Scanner evidence HTTP contract returns the same optional analysisMethod without renaming or interpreting it. Consumers must join by both CVE and component PURL: a stronger record for the same CVE on another package is not evidence for the requested finding.

Smart-Diff runtime boundary (verified 2026-07-17): the WebService registers PostgresMaterialRiskChangeRepository, PostgresVexCandidateStore, ManifestBackedScanMetadataRepository, and the Smart-Diff read/review/SARIF routes. It does not register or call MaterialRiskChangeDetector or VexCandidateEmitter, and neither class has a production caller in Scanner. Consequently the tables and APIs are durable retrieval/review surfaces, not a live automatic scan-completion pipeline. Wiring automatic VEX generation first requires a production lifecycle owner that supplies real previous/current finding snapshots, vulnerable-API inventories, call graphs, and per-finding reachability gates; missing inputs must not be synthesized as unknown evidence.

3.3 Object store layout (RustFS)

layers/<sha256>/sbom.cdx.json.zst
layers/<sha256>/sbom.spdx.json.zst
images/<imgDigest>/inventory.cdx.pb            # CycloneDX Protobuf
images/<imgDigest>/usage.cdx.pb
indexes/<imgDigest>/bom-index.bin              # purls + roaring bitmaps
diffs/<old>_<new>/diff.json.zst
attest/<artifactSha256>.dsse.json              # DSSE bundle (cert chain + Rekor proof)

RustFS exposes a deterministic HTTP API (PUT|GET|DELETE /api/v1/buckets/{bucket}/objects/{key}). Scanner clients tag immutable uploads with X-RustFS-Immutable: true and, when retention applies, X-RustFS-Retain-Seconds: <ttlSeconds>. Additional headers can be injected via scanner.artifactStore.headers to support custom auth or proxy requirements. RustFS provides the standard S3-compatible interface for all artifact storage.


4) REST API (Scanner.WebService)

Endpoints are registered under the configured API base path (/api/v1 by default) in Program.cs; scan-scoped routes live in Endpoints/ScanEndpoints.cs and its sub-mappers. Auth: OpTok (DPoP/mTLS); per-endpoint scope policies (see §10). The route table below is the authoritative core surface; the WebService also registers many additional endpoint groups (see note after the table).

# Scan lifecycle (ScanEndpoints.cs) — scans segment configurable, default /scans
POST /scans                          { image:{reference|digest} | artifactRef/artifactDigest, force?, clientRequestId?, metadata? }
                                       → 202 Accepted { scanId, status, location, created }   # scope: scanner.api (any-of scanner:scan/write/...)
GET  /scans/{scanId}                 → 200 { scanId, status, image, createdAt, updatedAt, failureReason?, entropy?, surface?, replay?, attestation? }  # scanner.scans.read
POST /scans/{scanId}/entropy         → 202 (attach layer entropy snapshot)                    # scanner.scans.write
GET  /scans/{scanId}/events          → SSE/JSONL progress stream                              # scanner.scans.read
GET  /scans/{scanId}/entrytrace      → 200 EntryTrace graph + best plan                       # scanner.scans.read
GET  /scans/{scanId}/ruby-packages   → 200 { scanId, imageDigest, generatedAt, packages[] }   # scanner.scans.read
GET  /scans/{scanId}/bun-packages    → 200 { scanId, imageDigest, generatedAt, packages[] }   # scanner.scans.read

# SBOM submit + export (SbomEndpoints.cs / ExportEndpoints.cs), per scan
POST /scans/{scanId}/sbom            (CycloneDX 1.7/1.6, SPDX, JSON body) → 202 { sbomId, format, componentCount, digest }  # scanner.scans.write
GET  /scans/{scanId}/exports/sbom    ?format=spdx2|spdx3|cyclonedx&profile=software|lite|build|security (default SPDX 2.3; content-negotiated CycloneDX-XML / SPDX-3 JSON-LD)  # scanner.scans.read
GET  /scans/{scanId}/exports/sarif   → application/sarif+json                                  # scanner.scans.read
GET  /scans/{scanId}/exports/cdxr    → CycloneDX 1.7 with reachability                         # scanner.scans.read
GET  /scans/{scanId}/exports/openvex → OpenVEX JSON                                            # scanner.scans.read
GET  /scans/{scanId}/exports/signed-sbom-archive ?compression=gzip|zstd → fail-closed (409 if no verified material, 422 if invalid)  # scanner.scans.read
POST /scans/{scanId}/exports/signed-sbom-material  (producer handoff; verifies then upserts)   # scanner.signed-sbom-material.write

# Manifest / proofs (ManifestEndpoints.cs) — durable in scanner.scan_manifest / scanner.proof_bundle
GET  /scans/{scanId}/manifest                                                                  # scanner.scans.read
GET  /scans/{scanId}/proofs                                                                    # scanner.scans.read
GET  /scans/{scanId}/proofs/{rootHash}                                                         # scanner.scans.read

# Score replay (ScoreReplayEndpoints.cs; gated by ScoreReplay.Enabled). Legacy /score/{scanId}/* aliases retained.
POST /scans/{scanId}/score/replay    # scanner.scans.write     GET /scans/{scanId}/score/bundle   # scanner.scans.read
POST /scans/{scanId}/score/verify    # scanner.scans.write     GET /scans/{scanId}/score/history  # scanner.scans.read

# Reachability (ReachabilityEndpoints.cs), per scan
POST /scans/{scanId}/compute-reachability     → 202 (501 until a durable reachability backend is wired)  # scanner.scans.write
GET  /scans/{scanId}/reachability/components|findings|explain                                  # scanner.scans.read
GET  /scans/{scanId}/reachability/traces/export → 200 (sensor_gap or callable_path_proof, never 501)  # scanner.scans.read

# Reports (ReportEndpoints.cs) — delegates to Policy preview; returns signed report DTO inline
POST /reports                        { imageDigest, findings?, baseline? } → 200 { report, dsse?, attestation }  # scanner.reports
                                       (503 when no policy snapshot)
GET  /reports/artifact-vulnerabilities?subject=<artifactRef|digest>&digest=<optional>
                                       → 200 ArtifactVulnerabilityReport, 404 when no tenant-scoped persisted report exists  # scanner.reports

The artifact vulnerability report resolver is the canonical Scanner-owned
source for audit-bundle vulnerability material keyed by artifact. The resolver
uses the tenant-scoped latest row in `scanner.scan_runtime_state` for an
`artifactRef` and/or digest, preferring digest when both are supplied, then reads
the persisted public reachability verdicts for that scan's image digest. It
returns schema `stellaops.scanner.artifact-vulnerability-report.v1` with
`source = scanner.public-reachability-verdicts`, the resolved `scanId`,
`scanStatus`, `scanUpdatedAt`, summary counts, and the artifact-scoped findings
array. It does not synthesize findings from policy previews, placeholder CVEs,
or tenant-wide findings lists.

Absent data is an expected control-plane outcome: unknown artifacts,
cross-tenant artifacts, scans without persisted reachability verdicts, and
unavailable reachability storage return `404`/null rather than placeholder JSON
or a `500`. Callers must pass the authenticated tenant context and must preserve
that null behavior when building audit bundles.

# SBOM upload (BYOS) + hot lookup + evidence (SbomUploadEndpoints.cs and siblings)
POST /sbom/upload, GET /sbom/uploads/{sbomId}                                                  # see byos-ingestion.md

# ProofSpine (ProofSpineEndpoints.cs)
GET  /spines/{spineId}      GET /scans/{scanId}/spines

# Registry lookup (RegistryEndpoints.cs, group prefix /registries) — 501 Not Implemented until a registry integration is configured
GET  /registries/images/search   GET /registries/images/digests                               # scanner.scans.read

# Tenant Scanner settings (ScannerTenantSettingsEndpoints.cs)
GET  /scanner/settings            -> 200 { disabledAnalyzerIds[], etag, updatedAt?, updatedBy? } # scanner:read | scanner.admin
PUT  /scanner/settings + If-Match -> 200 updated settings; 412 stale; 428 missing precondition   # scanner:write | scanner.admin
GET  /scanner/analyzers           -> 200 { sourceStatus, observedAt?, analyzers[] }               # scanner:read | scanner.admin
GET  /scanner/performance         -> 200 { window, metrics, omittedMetrics[] }                     # scanner:read | scanner.admin

# Health / build / observability (not under the API base path)
GET  /healthz | /readyz | /buildinfo.json | observability + offline-kit routes

Beyond the core surface above, Program.cs also maps these endpoint groups (see endpoint-registration-matrix.md for the full inventory and scopes): sources + SCM bulk import (/sources, /scm/...), webhooks, delta-compare / smart-diff / baseline / actionables / counterfactual, replay, witness, EPSS, triage (/triage/inbox, clusters, status, proof bundles, batch actions), /unknowns, secret-detection settings + secret bundles, scan-policy CRUD, policy preview (feature-gated), runtime ingest, reachability stack + evidence, advanced-assurance QA fixtures, GitHub code-scanning, validation, fidelity, layer SBOM, call graph, approvals, evidence, slice, VEX-reachability filter (/scans/vex-reachability/filter), and the VEX gate read endpoints (currently 501). Baseline recommendation/rationale route shapes are also reserved but currently return deterministic HTTP 501 scanner_baseline_recommendations_unavailable: the prior in-memory implementation synthesized digests, release versions, verdicts, and timestamps and is intentionally unwired until Scanner owns persisted, tenant-scoped known-good baseline facts. There is no legacy GET /sboms/{imageDigest}, GET /diff, POST /exports, or GET /catalog/artifacts/{id} route — those were superseded by the per-scan export routes, the smart-diff/delta-compare endpoints, and the catalog being internal to Storage.

Source create/update keeps the pre-flight tenant/name uniqueness check for clear operator feedback, and also translates late PostgreSQL 23505 violations on uq_sbom_sources_tenant_name into the existing 409 scanner.sources.already_exists / update-conflict endpoint responses when concurrent writers race.

See docs/modules/scanner/byos-ingestion.md for BYOS workflow, formats, and troubleshooting.

4.0 Tenant settings contract

GET|PUT /api/v1/scanner/settings is a tenant-scoped operator surface with split least-privilege policies: scanner.operations.read accepts canonical scanner:read (or legacy scanner.admin) for GET, while scanner.operations.write accepts canonical scanner:write (or legacy scanner.admin) for PUT. This keeps Console reads reachable and gives explicitly provisioned writers an audited update path without granting the deliberately unseeded escape-hatch admin scope. The default stella-ops-ui and professional interactive role bundles omit scanner:write, so their settings view remains read-only. The first frozen contract exposes one real pipeline control: the normalized, sorted list of admitted signed analyzer IDs that Scanner Worker must skip. It does not expose registry credentials, host paths, global plug-in admission controls, or configuration-only values that the scan pipeline ignores.

The strong response ETag is a SHA-256 hash of the canonical settings document. PUT requires exactly one strong If-Match; a stale compare-and-swap returns 412, while a missing precondition returns 428. Successful updates emit a Timeline audit event containing actor, tenant, timestamp, and before/after analyzer IDs and ETags. Storage is the auto-migrated scanner.tenant_settings table. Worker resolves the queue job tenant and filters matching signed OS/language analyzer IDs before dispatch; a settings repository failure is not swallowed into an unsafe enabled-by-default run. The complete request, response, validation, and error contract is scanner-tenant-settings-api.md.

GET /api/v1/scanner/analyzers reads Scanner Worker’s canonical signed-plugin probe report from the existing shared scanner-plugin-scratch volume and overlays the current tenant’s disabled IDs. It maps accepted/responded evidence to healthy, explicit rejected/failed evidence to unhealthy, and all absent or incomplete signals to unknown; it never synthesizes a degraded state. lastSuccessfulProbeAt is the report observation time only for accepted/responded rows. Scanner does not persist per-analyzer last successful scan execution, so no such timestamp is claimed. Missing or invalid reports return sourceStatus=unavailable; tenant-disabled IDs remain visible as unknown so operators can reverse them. The frozen response contract is scanner-analyzers-api.md.

GET /api/v1/scanner/performance is a small tenant-scoped read model over durable Scanner records, not browser-side Prometheus scraping. Current pending depth is counted from scanner.scan_runtime_state rows in Pending; duration p50/p95 is derived from non-replay scanner.scan_metrics.total_duration_ms rows in the requested window. A missing duration population omits the duration object instead of returning zero. Windowed failure rate remains omitted because the runtime-state model does not persist an immutable terminal outcome timestamp. The contract and supported windows are frozen in scanner-performance-api.md.

4.1 Manifest and proof persistence contract

4.1.1 Forward-only migration 011 cutover

Migration 011_sprint20260719_byos_upload_tenant_isolation.sql intentionally replaces the historical global sbom_id primary key with (tenant_id, sbom_id). That change is required for tenant isolation, but it is not backward-compatible with a historical Scanner Web instance whose BYOS upsert used sbom_id as its only conflict identity. Do not leave an old scanner-web serving while a new service applies migration 011.

The safe single-instance cutover sequence is:

  1. Freeze coherent Scanner Worker and Scanner Web images from the same source candidate. Drain Router traffic to the old scanner-web, then stop it.
  2. Recreate the new scanner-worker first. Its Scanner Storage startup runner must apply migrations 010 and 011 before the Worker becomes the accepted candidate.
  3. Verify the migration ledger and PostgreSQL catalog: scanner.tenant_settings exists; scanner.sbom_uploads.tenant_id is NOT NULL; and the table primary key columns are exactly (tenant_id, sbom_id). Also verify the Worker image identity, healthy/restart-zero state, clean startup/migration logs, and a current analyzer probe-report.json on scanner-plugin-scratch.
  4. Recreate the matching new scanner-web, verify its exact image identity, healthy/restart-zero state and Router re-registration, then restore traffic. Authenticated gateway reads of /api/v1/scanner/settings, /api/v1/scanner/analyzers, and /api/v1/scanner/performance are the minimum read-only API forcing checks.

Scanner migrations are forward-only. After migration 011, prefer rolling forward to the coherent new Worker/Web pair. Do not restart an old Scanner image against the upgraded schema. A true rollback requires the pre-migration PostgreSQL snapshot plus the coordinated old image pair while Router traffic remains drained; a schema-only downgrade is not supported. A cross-tenant same-SBOM live probe additionally creates durable acceptance records, so run it only with dedicated tenants and explicit retention/cleanup authority. The disposable-PostgreSQL regression remains the non-mutating proof for ordinary deployments.

4.2 Localization runtime contract (Sprint 20260224_002)

4.3 Patch verification signature-store contract

Report events

When scanner.events.enabled = true, the WebService serialises the signed report (canonical JSON + DSSE envelope) with NotifyCanonicalJsonSerializer and publishes two Redis Stream entries (scanner.report.ready, scanner.scan.completed) to the configured stream (default stella.events). The stream fields carry the whole envelope plus lightweight headers (kind, tenant, ts) so Notify and UI timelines can consume the event bus without recomputing signatures. Publish timeouts and bounded stream length are controlled via scanner:events:publishTimeoutSeconds and scanner:events:maxStreamLength. If the queue driver is already Redis and no explicit events DSN is provided, the host reuses the queue connection and auto-enables event emission so deployments get live envelopes without extra wiring. Compose/Helm bundles expose the same knobs via the SCANNER__EVENTS__* environment variables for quick tuning.


5) Execution flow (Worker)

5.1 Acquire & verify

  1. Resolve image (prefer repo@sha256:…).
  2. (Optional) verify image signature per policy (cosign).
  3. Pull blobs, compute layer digests; record metadata.

5.2 Layer union FS

5.2.1 Pipeline stage executors (pull-layers, build-filesystem, sbom-match)

Sprint SPRINT_20260522_002. Before this work the Worker had no executor for pull-layers or build-filesystem, so a real-image scan reported Succeeded while producing zero output (no rootfs → every analyzer short-circuited). These stages now turn an image reference into bytes on disk and forward the emitted PURLs to Concelier’s matcher.

pull-layers(PullLayersStageExecutor, StageName => pull-layers):

A manifest not found in the configured registry persists the bounded reason pull failed: image was not found in the configured registry; use a fully qualified reference or mirror the image. The submitted reference and registry remain only in structured worker logs.

build-filesystem(BuildFilesystemStageExecutor, StageName => build-filesystem):

These metadata keys are written into the lease via a MutableScanJobLease decorator (the production lease metadata is otherwise read-only), so every downstream reader of context.Lease.Metadata observes them with no further plumbing.

sbom-match(SbomMatchStageExecutor, StageName => sbom-match, runs right after execute-analyzers):

Configuration (Scanner:Worker:Pipeline, env prefix SCANNER_)

KeyEnvDefaultPurpose
DefaultRegistrySCANNER__SCANNER__WORKER__PIPELINE__DEFAULTREGISTRYstellaops-registry:5000Registry used when a ref omits one; a single-label host equal to this host is treated as the registry. The in-cluster registry listens on :5000 (not the implicit http :80), so the default carries the explicit port and a bare stellaops-registry/library/... ref also resolves to :5000.
AllowInsecure…__PIPELINE__ALLOWINSECUREtruePlain-HTTP / non-TLS access for the internal stellaops-registry (worker has no docker.io egress in the air-gapped posture).
Platform…__PIPELINE__PLATFORMlinux/amd64Platform resolved from a multi-arch index.
RegistryUsername / RegistryPassword…__PIPELINE__REGISTRYUSERNAME / …REGISTRYPASSWORD(unset)Optional basic-auth for private registries.
WorkRoot…__PIPELINE__WORKROOT(OS temp)Root for the per-scan layer cache + rootfs.
CleanupRootFilesystem…__PIPELINE__CLEANUPROOTFILESYSTEMtrueRemove the per-scan work root after the job.
MaxExtractedBytes…__PIPELINE__MAXEXTRACTEDBYTES8 GiBDecompression-bomb guard.
Matcher:Enabled…__PIPELINE__MATCHER__ENABLEDtrueForward emitted PURLs to Concelier /learn/sbom.
Matcher:LearnUrl…__PIPELINE__MATCHER__LEARNURLhttp://concelier.stella-ops.local/api/v1/learn/sbomGateway-routed learn endpoint.
Matcher:DefaultTenant…__PIPELINE__MATCHER__DEFAULTTENANTdefaultTenant used when lease metadata carries none.

An image reference without a registry, such as nginx:alpine, resolves against DefaultRegistry; it does not imply Docker Hub. In the default air-gapped profile the operator must mirror the image into stellaops-registry:5000 or submit a fully qualified reference to an explicitly configured reachable registry.

When AllowInsecure=true, Scanner treats any loopback IP literal as a local HTTP registry, not only localhost and 127.0.0.1. This keeps local proof registries such as 127.1.1.5:80 on HTTP for both image inspection and OCI artifact pushes. Upload Location headers are resolved relative to the started upload URI, so registries that return relative upload locations preserve the selected HTTP/HTTPS scheme. OCI descriptor fields with no value are omitted rather than serialized as JSON null, matching registries that validate descriptor schemas strictly.

OS analyzer plug-in packaging. The base mounted Scanner profile carries APK, DPKG, and RPM as canonical runtime bundles under devops/plugins/scanner/base/stellaops.scanner.plugin.os.{apk,dpkg,rpm}. Each bundle contains the OS analyzer DLL payload, a DSSE-signed ScannerPluginManifest (manifest.json) generated from the tracked analyzer project manifest source, and checksums.sha256 for admission inventory. The signed manifest IDs are stellaops.scanner.plugin.os.apk, stellaops.scanner.plugin.os.dpkg, and stellaops.scanner.plugin.os.rpm, with purl-prefix contracts matching current emission (pkg:alpine/, pkg:deb/, pkg:rpm/).

As of commit 5a47e7e665 (2026-06-07, WS1) the legacy unsigned OS plug-in path has been hard-cut: the worker scan execution path no longer activates OS analyzers through OsAnalyzerPluginCatalog (deleted, along with the trust-less IOSAnalyzerPlugin contract it loaded via PluginHost.LoadPlugins with no DSSE admission). OS analyzers are now sourced from ScannerOsAnalyzerHost, which reuses the language-analyzer gold-standard ScannerPluginLoader DSSE / trust-store / assembly-SHA-256 admission gate via the new signed IScannerOsAnalyzerPlugin contract (detailed immediately below). CompositeScanAnalyzerDispatcher, Program.cs, and ScannerAnalyzerProbeReportService all consume the signed host. All 12 OS analyzer plug-ins (apk/dpkg/rpm + homebrew/macosbundle/pacman/pkgutil/ windows.chocolatey/windows.msi/windows.winsxs + portage) implement the new contract, and the 7 non-base OS bundles are produced + DSSE-verified. Missing/untrusted OS bundles still cause the worker No OS analyzer plug-ins discovered … scanning will skip OS package extraction and emits only pkg:generic/* native-binary PURLs — the SBOM→matcher path then produces 0 canonical matches for the scanned image.

Scanner OS analyzer signed contract (IScannerOsAnalyzerPlugin). Commit 5a47e7e665 added IScannerOsAnalyzerPlugin (src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Plugin/IScannerOsAnalyzerPlugin.cs) as the signed-bundle counterpart to IScannerLanguageAnalyzerPlugin. It exposes Id / DisplayName / Version / IsAvailable(IServiceProvider) / CreateAnalyzer(IServiceProvider) and returns the existing IOSPackageAnalyzer, so every behavioral contract is preserved: OsComponentFragments, the busybox / musl reachability heuristics, the os-package component type, and the OsPackageAnalyzers analysis key. Id must equal the manifest id (ordinal), and Version must equal the manifest version.

ScannerOsAnalyzerHost (src/Scanner/StellaOps.Scanner.Worker/Plugins/ScannerOsAnalyzerHost.cs, wired via AddScannerOsAnalyzerHost in ScannerOsAnalyzerHostExtensions) hosts the contract on the same ScannerPluginLoader admission path the language analyzers use: it scans each immediate sub-directory of the OS plugin root for a signed manifest.json, verifies the DSSE/EdDSA signature against the trust store, checks the assembly SHA-256 and signed StellaOps.Scanner.Analyzers.OS version/digest stamp, then activates the declared entry type. ScannerOsAnalyzerHostOptions mirrors ScannerAnalyzerHostOptions and exposes the fail-closed switches RequirePluginDirectory, RequireTrustStoreDirectory, RequireTrustedKeys, and RequireLoadedAnalyzers (all default false, set true by the overlay). The host default signature verifier is Loader’s Ed25519AsymmetricSignatureVerifier (which delegates non-EdDSA algorithms to DefaultAsymmetricSignatureVerifier.Instance) because the in-tree OS manifests are DSSE-signed with EdDSA.

Tests: 11 OS-host admission tests (every reject path admits zero analyzers) + the dispatcher OS tests rewired to the new host (every behavioral assertion preserved, none weakened) + 7 new AllAnalyzerManifestsLoadTests forcing-functions. The base scanner bundles were restored byte-identical (devops/plugins/scanner tree clean).

Opt-in compose overlay: devops/compose/docker-compose.plugins.scanner-os.yml mounts the signed OS analyzer bundle root (devops/plugins/scanner/analyzers/os/*/app/plugins/scanner/analyzers/os) and the Scanner plugin trust store (devops/etc/certificates/trust-roots/plugins/scanner/app/trust-roots/plugins/scanner) read-only onto both scanner-web and scanner-worker, points ScannerAnalyzerHost__PluginDirectory / ScannerAnalyzerHost__TrustStoreDirectory at those mounts, and sets all four Require*=true switches so unsigned/untrusted analyzer bundles are rejected (supply-chain boundary). Apply with COMPOSE_EXTRA_FILES=docker-compose.plugins.scanner-os.yml ./scripts/compose-cli.ps1 up. Bundle producer: devops/build/package-runtime-plugins.ps1 -Module scanner (signed analyzer bundles, OS profile).

Note: the lang analyzers were already signed and load through the same ScannerPluginLoader; this slice closes the OS gap so all analyzer categories share one signed admission path.

Live runtime probe is pending. The contract, host, dispatcher rewire, producer blocks, and overlay are committed (5a47e7e665), but a live overlay-up acceptance probe against a running scanner (mount the signed OS bundles, scan an image, confirm OS-package PURLs + admitted rows on /internal/plugins/probe) has not yet been recorded.

Language analyzer bundle packaging. Generic language analyzers load through ScannerAnalyzerHost and the DSSE-verified ScannerPluginLoader. The approved base language bundle is dotnet, golang, java, node, and python; the optional full overlay adds bun, ccpp, dart, deno, elixir, php, ruby, rust, and swift, yielding the complete Bun, Ccpp, Dart, Deno, DotNet, Elixir, Go, Java, Node, Php, Python, Ruby, Rust, and Swift language set. The full overlay mounts that complete set at /app/plugins/scanner/analyzers; the E2E analyzer-coverage override uses the same mounted full bundle while keeping its relaxed synthetic, secrets, and native testing settings out of base compose. Each language bundle is an immediate child of plugins/scanner/analyzers/ and contains a signed manifest.json, the analyzer assembly, transitive DLLs/deps/runtime files, and analyzer-owned static data. The manifest assembly.sha256 must match the assembled DLL bytes. Its signed metadata must also match the Worker’s current StellaOps.Scanner.Analyzers.Lang, LangAdapter, and Plugin.Contracts assemblies byte-for-byte. package-runtime-plugins.ps1 -Module scanner -Profile scanner-runtime publishes these analyzers from current source and produces both base and full profiles, including the complete published runtime dependency closure for every OS analyzer. build-service-publish.sh scanner-worker invokes that step after the Worker publish, overlays those exact contract assemblies into the image context, and then builds the service image. Production trust roots belong under etc/scanner/trust-store/scanner-plugins, mounted read-only by operators; the current worker startup keeps the dev trust-store path as an explicit compatibility override until SCN-PLUG-002 moves bundle assembly/signing out of service images. Once compose mounts the base bundle, deployments should enable ScannerAnalyzerHost__RequirePluginDirectory=true, ScannerAnalyzerHost__RequireTrustStoreDirectory=true, ScannerAnalyzerHost__RequireTrustedKeys=true, and ScannerAnalyzerHost__RequireLoadedAnalyzers=true to fail closed on missing or invalid analyzer bundles.

5.3 Evidence harvest (parallel analyzers; deterministic only)

A) OS packages

Data flow note: Each OS analyzer now writes its canonical output into the shared ScanAnalysisStore under analysis.os.packages (raw results), analysis.os.fragments (per-analyzer layer fragments), and contributes to analysis.layers.fragments (the aggregated view consumed by emit/diff pipelines). Helpers in ScanAnalysisCompositionBuilder convert these fragments into SBOM composition requests and component graphs so the diff/emit stages no longer reach back into individual analyzer implementations. Before those fragments are emitted, OsComponentMapper resolves package-manager relation strings against the installed packages reported by the same analyzer. Dpkg parenthesized constraints and architecture qualifiers, apk/rpm operator suffixes, ordered alternatives, and unique virtual-package Provides matches are normalized to the installed component’s PURL. CycloneDX dependencies[].dependsOn therefore contains only refs present in the same document. Missing, self-only, or ambiguous matches are never turned into phantom nodes; each component reports deterministic stellaops.os.dependencies.resolvedCount, stellaops.os.dependencies.unresolvedCount, and (when non-zero) an ordinal stellaops.os.dependencies.unresolvedRelations list. Layer fragments, dependency PURLs, and unresolved relation reports are ordinal-sorted so identical installed-package input emits byte-identical dependency output. RPM and DPKG changelog evidence now also emits deterministic vendor metadata keys vendor.changelogBugRefs and vendor.changelogBugToCves for Debian Closes, RHBZ#, and Launchpad LP bug-to-CVE correlation traces used during backport triage.

B) Language ecosystems (installed state only)

Rule: We only report components proven on disk with authoritative metadata. Lockfiles are evidence only.

C) Native link graph

D) EntryTrace (ENTRYPOINT/CMD → terminal program)

D.1) Semantic Entrypoint Analysis (Sprint 0411)

Post-resolution, the SemanticEntrypointOrchestrator enriches entry trace results with semantic understanding:

Language-specific adapters (PythonSemanticAdapter, JavaSemanticAdapter, NodeSemanticAdapter, DotNetSemanticAdapter, GoSemanticAdapter) recognize framework patterns:

Semantic data flows into:

See docs/modules/scanner/operations/entrypoint-semantic.md for full schema reference.

EntryTrace-local binary intelligence (verified 2026-07-19): resolved native terminal files are read from the extracted root, split into deterministic function windows, fingerprinted, and checked against literal CVE markers in the same local binary before an EntryTraceBinaryIntelligence payload is stored and served by the EntryTrace API. Native terminal path resolution is lexically contained with Path.GetRelativePath; rooted and parent-relative escapes are rejected. This bounded path is distinct from the BinaryIndex catalog and disassembly pipeline described below.

E) Binary Vulnerability Lookup (Sprint 20251226_014_BINIDX)

The BinaryLookupStageExecutor enriches scan results with binary-level vulnerability evidence:

BinaryFindingMapper converts matches to standard findings format with BinaryFindingEvidence:

public sealed record BinaryFindingEvidence
{
    public required string BinaryKey { get; init; }
    public string? BuildId { get; init; }
    public required string MatchMethod { get; init; }  // buildid_catalog, fingerprint_match, range_match
    public required decimal Confidence { get; init; }
    public string? FixedVersion { get; init; }
    public string? FixStatus { get; init; }  // fixed, vulnerable, not_affected, wontfix
}

Proof Segments: The Attestor generates binary_fingerprint_evidence proof segments with DSSE signatures for each binary with vulnerability matches. Schema: https://stellaops.dev/predicates/binary-fingerprint-evidence@v1.

UI Badges: Scan results display status badges:

CLI Commands (Sprint 20251226_014):

F) Attestation & SBOM bind (optional)

5.4 Component normalization (exact only)

5.5 SBOM assembly & emit

The emitted buildId metadata is preserved in component hashes, diff payloads, and /policy/runtime responses so operators can pivot from SBOM entries → runtime events → debug/.build-id/<aa>/<rest>.debug within the Offline Kit or release bundle.

5.5.1 Service security analysis (Sprint 20260119_016)

When an SBOM path is provided, the worker runs the service-security stage to parse CycloneDX services and emit a deterministic report covering:

Inputs are passed via scan metadata (sbom.path or sbomPath, plus sbom.format). The report is attached as a surface observation payload (service-security.report) and keyed in the analysis store for downstream policy and report assembly. See src/Scanner/docs/service-security.md for the policy schema and output formats.

Runtime reconciliation and downstream policy helpers now resolve scan metadata from persisted scan manifests rather than a standalone in-memory metadata store. This keeps metadata derivation restart-safe as long as the canonical manifest record exists.

5.5.2 CBOM crypto analysis (Sprint 20260119_017)

When an SBOM includes CycloneDX cryptoProperties, the worker runs the crypto-analysis stage to produce a crypto inventory and compliance findings for weak algorithms, short keys, deprecated protocol versions, certificate hygiene, and post-quantum readiness. The report is attached as a surface observation payload (crypto-analysis.report) and keyed in the analysis store for downstream evidence workflows. See src/Scanner/docs/crypto-analysis.md for the policy schema and inventory export formats.

5.5.3 AI/ML supply chain security (Sprint 20260119_018)

When an SBOM includes a declared AI/ML model component (including a component whose model card is absent), the worker runs the ai-ml-security stage to evaluate model governance readiness. The report covers model card completeness, training data provenance, bias/fairness checks, safety risk assessment coverage, and provenance verification. The report is attached as a surface observation payload (ai-ml-security.report) and keyed in the analysis store for policy evaluation and audit trails. AI/ML findings are not currently projected into Scanner SARIF output. See src/Scanner/docs/ai-ml-security.md for policy schema, CLI toggles, and binary analysis conventions.

5.5.4 Build provenance verification (Sprint 20260119_019)

When an SBOM includes CycloneDX formulation or SPDX build profile data, the worker runs the build-provenance stage to verify provenance completeness, builder trust, source integrity, hermetic build requirements, and optional reproducibility checks. The report is attached as a surface observation payload (build-provenance.report) and keyed in the analysis store for policy enforcement and audit evidence. Self-asserted SBOM parameters such as provenanceSigned=true, builderAttestationSigned=true, or commitSigned=true are not cryptographic proof and do not satisfy signed provenance, signed source, or SLSA level promotion requirements unless a verifier has validated the signature material. See src/Scanner/docs/build-provenance.md for policy schema, CLI toggles, and report formats.

5.5.5 SBOM dependency reachability (Sprint 20260119_022)

When configured, the worker runs the reachability-analysis stage to infer dependency reachability from SBOM graphs and optionally refine it with a richgraph-v1 call graph. Advisory matches are filtered or severity-adjusted using VulnerabilityReachabilityFilter, with false-positive reduction metrics recorded for auditability. The stage attaches:

Configuration lives in src/Scanner/docs/sbom-reachability-filtering.md, including policy schema, metadata keys, and report outputs.

The committed baseline stack leaves dependency reachability off by default. Operators enable it through the opt-in reachability overlay (or equivalent runtime configuration), and any non-harness host with Scanner:Worker:Reachability:Enabled=true must also configure Scanner:Worker:Reachability:AdvisoryBaseUrl to the Concelier canonical advisory service root. ScannerReachabilityRuntimeConfigurationValidator fails startup without that advisory URL so the local NullSbomAdvisoryMatcher cannot produce empty CVE evidence that looks like a clean scan. The existing live forcing-function is recorded at docs/qa/feature-checks/runs/reachability-live-forcing-function/RUN_REPORT.md and exercises this opt-in lane with the overlay layered.

If a local harness intentionally runs the null advisory matcher, the dependency reachability report marks summary.advisorySourceStatus=unavailable with summary.advisorySourceReason=advisory-source-unconfigured, and the surface manifest copies those fields into reachability.report metadata. A zero-CVE count is only a clean advisory result when the advisory source status is available.

The Worker publishes the rich reachability graph before PoE generation. PoE generation consumes the real RichGraphPublishResult plus explicit build, image, policy, and config metadata; missing or invalid graph hashes, build IDs, sha256 image digests, policy IDs/digests, config paths, or empty DSSE signing output fail closed with typed poe_* outcomes instead of emitting placeholder proof records.

PoE DSSE signing is not local by default. When PoE:Enabled=true, the Worker registers the PoE stage only after binding PoE:Signer:Provider=http-signer, which calls Signer POST /api/v1/signer/sign/dsse with the emitted PoE JSON as the predicate. The request includes:

The Worker does not register a null, file-key, or placeholder DSSE signer for enabled PoE. If Signer refuses the request or returns an empty/malformed DSSE envelope, PoE generation fails closed and no signed PoE result is stored.

PoE artifact CAS storage is owned inside Scanner.Worker via StellaOps.Scanner.Worker.Orchestration.PoECasStore and POE_CAS_ROOT; Worker must not take a direct Signals dependency solely to persist PoE JSON, DSSE envelopes, metadata, or Rekor proof bytes. This removes the direct Scanner-to-Signals CAS edge, but it does not by itself clear pluginized image acceptance: global Web SDK Router transport wiring and crypto plugin payloads remain separate scanner-worker image-audit blockers.

Local development can exercise the same Worker-to-Signer path with devops/compose/docker-compose.scanner-poe-dev.yml and docs/modules/scanner/operations/poe-dev-harness.md. The harness computes the local stellaops/scanner-worker:dev image digest, configures Signer’s dev entitlement registry and trusted Scanner digest list, requests a DPoP-bound signer:sign token from the dev scanner-poe-dev Authority client, generates a fresh Signer DPoP proof with ath, and starts the real signer plus scanner-worker services with PoE:Enabled=true. The up path starts Signer, posts a fixed DSSE request to the real Signer endpoint, asserts a non-empty envelope and signature, and only then starts Scanner Worker. This remains a dev harness: release pipelines must provide registry manifest digests, environment-specific Authority clients, DPoP key custody, and deployment-managed secret distribution.

5.5.6 VEX decision filter with reachability (Sprint 20260208_062)

Scanner now exposes a deterministic VEX+reachability matrix filter for triage pre-processing:

The read-side VEX gate endpoints (VexGateController, route base api/v1/scans: GET /api/v1/scans/{scanId}/gate-results, /{scanId}/gate-summary, and /{scanId}/gate-blocked, plus the GET /api/v1/scans/gate-policy read) no longer bind to a canonical in-memory store in live runtime. Until Scanner grows a durable VEX gate results backend, gate-results returns 501 Not Implemented with error = "vex_gate_results_unsupported" instead of inventing transient state.

5.5.7 Vulnerability-first triage clustering APIs (Sprint 20260208_063)

Scanner triage now includes deterministic exploit-path clustering primitives for vulnerability-first triage workflows:

POST /api/v1/triage/proof-bundle is a reserved generation shape rather than a live capability: IProofBundleGenerator has no implementation or DI registration, and its current contract is not tenant-bound. Keep the route out of operator workflows until a persisted, signed generator and hosted forcing test exist.

The inbox is also the canonical Scanner Triage identity bridge. Its required artifactDigest parameter is a tenant-scoped multi-row filter, while every paths[].findingIds[] value is the bare persisted triage_finding.id UUID and is consumed unchanged by /api/v1/triage/findings/{findingId}/evidence. Matcher-derived Findings read-model tokens are intentionally not accepted as aliases. Optional inbox parameters (filter, sortBy, similarityThreshold, and descending) are parsed explicitly so a digest-only request does not fail model binding before the handler.

5.5.8 Triage tenant isolation contract (Sprint 20260222_057)

Scanner triage and finding evidence APIs enforce tenant-aware access at endpoint, service, and persistence layers:

Quiet-triage gating has production single-finding, batch-status, scan-summary, and artifact-summary routes backed by GatingReasonService. Persisted reachability, policy, backport, and trusted VEX rows drive the primary gating reason in that order of precedence after explicit user muting. Scan summary identity distinguishes an existing scan with zero findings (200, zero buckets) from an absent or cross-tenant scan (404). The current aggregate is still narrower than the original gated-triage feature promise: the BulkTriageQueryWithGating* DTOs are not consumed by the ordinary bulk triage query, and FindingGatingStatusDto exposes reachability subgraph and delta pointers but not policy/VEX source, DSSE, or signature evidence references.

Unified evidence projects only stored Triage rows. It includes the linked scan record and finding lifecycle timestamps, selects equal-timestamp child rows by UUID for deterministic ordering, and leaves unproduced hashes, versions, verification results, and replay inputs null or unknown. Missing child evidence does not erase a real finding: it returns 200 with a sorted typed availability.missing[] list naming each producer and recovery action. Missing or cross-tenant findings return the same non-echoing 404 scanner.triage.finding_not_found contract with Scanner Triage producer and inbox-recovery metadata.

The legacy GET /api/v1/findings/{findingId}/evidence controller requires the canonical ScannerPolicies.TriageRead policy before tenant resolution or data access and keeps raw source inclusion fail-closed. A request with includeRaw=true and no exact evidence:raw claim returns HTTP 403 through the normal authorization result; the denial text is never passed as an authentication-scheme name. The evidence:raw value is not part of the canonical Authority scope catalog, so raw evidence is not a supported operator-facing capability until a separate authorization and exposure contract is designed, catalogued, and tested. Its production composition maps stored triage VEX and risk rows into the endpoint-local VexStatusInfo and ScoreInfo contracts, but currently leaves BoundaryInfo null. The richer SmartDiff BoundaryProof/VexEvidence and Signals ScoreExplanation records are separate owning-module contracts and are not nested by this endpoint.

The scan-scoped GET /api/v1/scans/{scanId}/evidence/{findingId} route accepts the documented CVE@PURL identity as a catch-all path value because ordinary PURLs contain /. Its EvidenceCompositionService.GetEvidenceAsync path combines the stored scan snapshot with reachability query/explain results, emits component metadata derived from the PURL, a path witness, deterministic integer-additive score contributions, freshness, and available manifest/spine/ callgraph references. That bounded response is not an SBOM slice or an attestation-chain verification result, and it does not query VEX or RichGraph: Vex and Boundary remain null. The richer unified triage endpoint and bundle exporter are separate contracts and must not be attributed to this route.

Development triage fixture quarantine (STI-1, 2026-07-11)

DeterministicTriageDemoCatalog is a development fixture, never a production evidence source or fallback. Access is centralized through ITriageDemoFixtureProvider and requires both conditions below:

  1. host environment is not Production; and
  2. scanner:triageDemo:enabled=true is explicitly configured (SCANNER__TRIAGEDEMO__ENABLED=true for environment configuration).

Production ignores the switch even when it is accidentally set. The catalog-only GET /api/v1/vulnerabilities, /status, and /{vulnId} routes return 404 problem details with code scanner.vulnerabilities.not_available when the fixture is disabled; they do not return a fabricated empty inventory. The MVC VulnerabilitiesController currently has no explicit ScannerPolicies.ScansRead authorization metadata. Client-side scanner:read preflight is a usability guard, not a server security boundary; adding endpoint enforcement remains a Scanner hardening follow-up before these routes can be treated as an authenticated production contract. Tenant-scoped Triage DB evidence and gating rows are always resolved before an enabled development fixture, so a real finding with the same identity wins. The Triage EF runtime model schema-qualifies those relations under scanner; reads do not depend on a deployment-specific PostgreSQL search_path. Missing unified evidence remains a typed 404 scanner.triage.finding_not_found without echoing the requested fixture-looking identity, and never falls back in the default or Production posture.

Every enabled fixture payload is visibly labeled isDemo=true, including vulnerability items/details, gating status/bucket summaries, and unified evidence. Real responses retain isDemo=false. The fixture remains entirely offline and deterministic, but its identifiers (asset-worker-prod, repeating UUIDs, demo-*, previous-demo-scan, and stella-demo-signer) are forbidden from production responses.

5.5.9 Unknowns API tenant activation (Sprint 20260222_057 follow-up)

Scanner now registers the /api/v1/unknowns endpoint group in Program.cs with explicit scanner.scans.read authorization and tenant-aware query semantics:

5.5.10 API-backed tenant table parity (Sprint 20260222_057 SCAN-TEN-13)

Scanner API flows that operate on tenant-partitioned tables now require tenant arguments at repository boundaries:

5.5.11 Reachability catalog packs (D7, Sprint 20260610_002 CATALOG-PACKS)

Capability-sink and entrypoint catalogs are versioned, DSSE-signed JSON data packs, not C# tables (D7, reachability-sink-strategy.md§7.4). SinkRegistry and the per-language sink matchers (Go/Python/Js/Java/Rust/Ruby/PhpSinkMatcher) are pure match engines over the loaded pack data.

5.6 DSSE attestation (via Signer/Attestor)


6) Three-way diff (image → layer → component)

6.1 Keys & classification

6.2 Algorithm (outline)

A = components(imageOld, key)
B = components(imageNew, key)

added   = B \ A
removed = A \ B
changed = { k in A∩B : version(A[k]) != version(B[k]) || origin changed }

for each item in added/removed/changed:
   layer = attribute_to_layer(item, imageOld|imageNew)
   usageFlag = usedByEntrypoint(item, imageNew)
emit diff.json (grouped by layer with badges)

Diffs are stored as artifacts and feed UI and CLI.


7) Build-time SBOMs (fast CI path)

Scanner.Sbomer.BuildXPlugin can act as a BuildKit generator:


8) Configuration (YAML)

Note: the snippet below is an illustrative shape, not a 1:1 binding map. The runtime queue transport enum is Redis/Nats (Valkey Streams is reached via the redis kind; see §1.1 and the Path A binding in §1.1, which uses kind=redis, streamName=scanner:jobs, consumerGroup=scanner-workers). Worker pipeline keys are documented in §5.2.1.

scanner:
  queue:
    kind: redis            # Valkey Streams (redis:// protocol) is the standard transport; NATS also supported
    redis:
      connectionString: "redis://cache.stella-ops.local:6379/0"
      streamName: "scanner:jobs"
      consumerGroup: "scanner-workers"
  postgres:
    connectionString: "Host=postgres;Port=5432;Database=scanner;Username=stellaops;Password=stellaops"
  s3:
    endpoint: "http://rustfs:8080"
    bucket: "stellaops"
    objectLock: "governance"   # or 'compliance'
  analyzers:
    # OS analyzers (apk/dpkg/rpm + pacman/portage; macOS homebrew/bundle/pkgutil; Windows msi/winsxs/chocolatey) and
    # language analyzers are loaded as restart-time plug-ins; this map is illustrative of toggles, not the full set.
    os: { apk: true, dpkg: true, rpm: true }
    lang: { java: true, node: true, bun: true, deno: true, python: true, go: true, dotnet: true, rust: true, ruby: true, php: true, ccpp: true, dart: true, elixir: true, swift: true }
    native: { elf: true, pe: true, macho: true }    # PE and Mach-O parsers are implemented
    entryTrace: { enabled: true, shellMaxDepth: 64, followRunParts: true }
  emit:
    cdx: { json: true, protobuf: true }
    spdx: { json: true }
    compress: "zstd"
  rekor:
    url: "https://rekor-v2.internal"
  signer:
    url: "https://signer.internal"
  limits:
    maxParallel: 8
    perRegistryConcurrency: 2
  policyHints:
    verifyImageSignature: false
    trustBuildTimeSboms: true

Worker PoE signing uses the top-level PoE section because the Worker binds it directly for startup validation:

PoE:
  Enabled: true
  SigningKeyId: "scanner-poe-prod-key"
  Signer:
    Provider: "http-signer"
    Url: "https://signer.internal/"
    ScannerImageDigest: "sha256:<64 hex scanner worker image digest>"
    ProofOfEntitlementFormat: "jwt"
    ProofOfEntitlement: "<deployment secret>"
    DpopProof: "<sender-bound proof>"
    # Use either BearerToken or Scanner:Worker:Authority client credentials.
    BearerToken: "<optional deployment secret>"
    SigningMode: "kms"
    ReturnBundle: "dsse+cert"

9) Scale & performance


10) Security posture


11) Observability & audit


12) Testing matrix

Ground-truth benchmark boundary

StellaOps.Scanner.Benchmark is an internal library boundary for loading the image/CVE manifest and calculating deterministic TP/FP/TN/FN, precision, recall, F1, and tier metrics. Classification reports include explicit false-negative and true-negative placeholders for expected items omitted by a scanner.

The broader corpus execution workflow is not production-composed. The separate StellaOps.Scanner.Benchmarks project currently defines ICorpusRunner, result models, regression checks, and result writing, but no concrete runner or service registration consumes them. src/Cli/StellaOps.Cli/Commands/BenchCommandBuilder.cs is likewise not attached to the supported command tree. A runnable benchmark must first add a concrete offline runner, a meaningful versioned corpus, DI/CLI wiring, and a forcing test that scans those inputs; test-only mock runners are not runtime evidence.


13) Failure modes & degradations


14) Optional plug-ins (off by default)


15) DevOps & operations


16) CLI & UI touch points


17) Roadmap (Scanner)

Status note (2026-05): Windows package analyzers (MSI/WinSxS/Chocolatey), PE and Mach-O native parsers, and deeper Rust metadata (RustCargoTomlParser/RustWorkspaceResolver) have landed — they are no longer roadmap items. Remaining hardening (GAC/SxS depth, stripped-binary call-graph breadth) is in flight.


Appendix A — EntryTrace resolution (pseudo)

ResolveEntrypoint(ImageConfig cfg, RootFs fs):
  cmd = Normalize(cfg.ENTRYPOINT, cfg.CMD)
  stack = [ Script(cmd, path=FindOnPath(cmd[0], fs)) ]
  visited = set()

  while stack not empty and depth < MAX:
    cur = stack.pop()
    if cur in visited: continue
    visited.add(cur)

    if IsShellScript(cur.path):
       ast = ParseShell(cur.path)
       foreach directive in ast:
         if directive is Source include:
            p = ResolveInclude(include.path, cur.env, fs)
            stack.push(Script(p))
         if directive is Exec call:
            p = ResolveExec(call.argv[0], cur.env, fs)
            stack.push(Program(p, argv=call.argv))
         if directive is Interpreter (python -m / node / java -jar):
            term = ResolveInterpreterTarget(call, fs)
            stack.push(Program(term))
    else:
       return Terminal(cur.path)

  return Unknown(reason)

Appendix A.1 — EntryTrace Explainability

Appendix A.0 — Replay / Record mode

EntryTrace emits structured diagnostics and metrics so operators can quickly understand why resolution succeeded or degraded:

ReasonDescriptionTypical Mitigation
CommandNotFoundA command referenced in the script cannot be located in the layered root filesystem or PATH.Ensure binaries exist in the image or extend PATH hints.
MissingFilesource/./run-parts targets are missing.Bundle the script or guard the include.
DynamicEnvironmentReferencePath depends on $VARS that are unknown at scan time.Provide defaults via scan metadata or accept partial usage.
RecursionLimitReachedNested includes exceeded the analyzer depth limit (default 64).Flatten indirection or increase the limit in options.
RunPartsEmptyrun-parts directory contained no executable entries.Remove empty directories or ignore if intentional.
JarNotFound / ModuleNotFoundJava/Python targets missing, preventing interpreter tracing.Ship the jar/module with the image or adjust the launcher.

Diagnostics drive two metrics published by EntryTraceMetrics:

Structured logs include entrytrace.path, entrytrace.command, entrytrace.reason, and entrytrace.depth, all correlated with scan/job IDs. Timestamps are normalized to UTC (microsecond precision) to keep DSSE attestations and UI traces explainable.

Appendix B — BOM-Index sidecar

struct Header { magic, version, imageDigest, createdAt }
vector<string> purls
map<purlIndex, roaring_bitmap> components
optional map<purlIndex, roaring_bitmap> usedByEntrypoint

Appendix C — Stack-Trace Exploit Path View (Sprint 061)

The Triage library provides a stack-trace–style visualization layer on top of ExploitPath clusters, designed for UI rendering as collapsible call-chain frames.

Models (StellaOps.Scanner.Triage.Models)

TypePurpose
StackTraceExploitPathViewRoot view: ordered frames, CVE IDs, severity label, collapsed state
StackTraceFrameSingle frame: symbol, role, source location, snippet, gate label
SourceSnippetSyntax-highlighted source extract at a frame location
FrameRoleEntrypoint / Intermediate / Sink / GatedIntermediate
StackTraceViewRequestBuild request with source mappings and gate labels

Frame Role Assignment

PositionHas GateRole
FirstEntrypoint
LastSink
MiddleNoIntermediate
MiddleYesGatedIntermediate

Collapse Heuristic

Paths with > 3 frames are collapsed by default in the UI (showing only entrypoint + sink). The user can expand to see the full call chain.

Service (IStackTraceExploitPathViewService)

Source Snippet Integration

When source mappings are provided (keyed by file:line), the service attaches SourceSnippet records to matching frames. This enables syntax-highlighted code display in the UI without requiring the scanner to store full source files.

Advisory Commitments (2026-02-26 Batch)

Advisory Gap Status (2026-03-05 Update)

Gaps translated in the 2026-03-04 advisory batch are now implemented in Scanner:

Delivered in: