Scanner Architecture
Source ownership, 2026-10-03. PCD-253 withdrew uncompiled
StellaOps.Scanner.AiCodeGuardoptions and service-contract drafts. The bounded production AI Code Guard remains an Integrations operation; the planned Scanner aggregate described inoperations/ai-code-guard.mdis not activated by these source files. See the per-file source disposition.
Verified against commit
942a18727c(2026-09-26). Re-verify with:grep -nE "^(app|apiGroup)\.Map" src/Scanner/StellaOps.Scanner.WebService/Program.cs(route families),ls src/Scanner/__Libraries/StellaOps.Scanner.Persistence/Migrations/(migration chain) andgrep -n "scanner-" devops/release/bundle/docker-compose.yml(deployables).
Scope. Implementation-ready architecture for the Scanner subsystem: WebService, Worker, analyzers, SBOM assembly (inventory & usage), per-layer caching, three-way diffs, artifact catalog (SeaweedFS object storage over its S3-compatible protocol + PostgreSQL; the native RustFS driver is legacy compatibility only), attestation hand-off, and scale/security posture. This document is the contract between the scanning plane and its consumers plus producer-owned runtime seams (Policy, Vulnerabilities, Release Orchestrator, UI, and CLI). Names that still carry
Concelier,ExcititororBinaryIndexin Scanner source (for example thescanner:concelierlinkset options orConcelierLearnTokenHandler) are historical type names; those services are retired and the live owners are named where each seam is described.Deployables. Two containers ship from this module:
scanner-web(src/Scanner/StellaOps.Scanner.WebService, port 8444, network aliasscanner.stella-ops.local) andscanner-worker(src/Scanner/StellaOps.Scanner.Worker), plus the one-shotscanner-cache-initvolume-permission job (devops/release/bundle/docker-compose.yml). The formerStellaOps.Scanner.CartographerWeb SDK project was withdrawn tosrc/__Obsoleted/Scanner/by PCD-73 (2026-09-27); graph-api answers thecartographer.stella-ops.localalias,src/Scanner/StellaOps.Scanner.Sbomer.BuildXPluginis a CLI tool, andsrc/Scanner/StellaOps.Scanner.Analyzers.Nativeis a class library (ELF/PE/Mach-O readers) referenced by the CLI,StellaOps.Scanner.Reachabilityand tools. Related:docs/modules/scanner/operations/ai-code-guard.mdStorage 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.
- Scanner does not own verdict logic. Policy decides presentation and verdicts using producer-owned Vulnerabilities knowledge contracts. The
POST /reportsendpoint surfaces a verdict (pass/warn/blocked/escalated) only throughIPolicyOwnerClient, which calls Policy’s authenticated, exact-tenant Scanner owner API over the BCL-onlyStellaOps.Policy.Scanner.Contracts; Scanner has no Policy implementation or database edge and computes no policy decision. Advisory matching and affected-symbol reads use Scanner-owned contracts over the authenticated HTTP owner boundary of the Vulnerabilities hub (Scanner:Worker:Reachability:HubAdvisoryBaseUrl); no Concelier source is compiled into the Worker (src/Scanner/StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj). - Scanner does not keep third-party SBOM warehouses. It may bind to existing attestations for exact hashes.
- Core analyzers are deterministic (no fuzzy identity). Optional heuristic plug-ins (e.g., patch-presence) run under explicit flags and never contaminate the core SBOM.
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.
Approval tenant binding (PCD-864, 2026-10-18). Each approval route (create, list, read and revoke) first resolves the scan through the tenant-aware IScanCoordinator, which reads the scan’s persisted owner in scanner.scan_runtime_state. A scan of another tenant, an unknown scan and a request without a tenant claim all answer 404 with no body before any approval is created, listed, read or revoked. The approval store is keyed by the scan’s tenant, the scan and the finding, and the attestation chain verifier looks approvals up under the chain input’s tenant. Approvals stay process-local: they are not persisted and are not shared between replicas. 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 Evidence transparency log at POST /api/evidence/v1/rekor/entries. ScanAttestationServiceTokenHandler preserves an existing Authorization header. When that header is absent and the request tenant can be resolved, it attempts to attach a tenant-scoped service bearer using the configured AttestorServiceTokenScope (default attest:create); the request header selects the tenant for token minting. An absent auth client or failed token mint leaves the request without that service identity and logs the cause; the submitter’s non-success response remains fail-visible rather than gaining tokenless access. Evidence obtains the authenticated tenant from the token claim; tokenless trusted-network bypass is removed. Source-reverified 2026-10-07 at c0b0490da306d66a818486b09ae9fd440a3bda1b in the current submitter, typed client registration, token handler, and Evidence authorization policies.
Submission failures are classified at the HTTP boundary, never by parsing an exception message:
| Attestor outcome | Failure class | Retryable | Report status |
|---|---|---|---|
| HTTP 4xx | validation_rejected | no | rejected |
| HTTP 5xx | backend_unavailable | yes | self-signed |
| Network transport failure | backend_unavailable | yes | self-signed |
| Timeout (when the caller did not cancel) | backend_unavailable | yes | self-signed |
| Caller cancellation | n/a | n/a | cancellation propagates |
| Successful entry with id and log index | n/a | n/a | attested |
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 .runtime/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}. package-runtime-plugins.ps1 -Module scanner -Profile base stages that bundle (Copy-ScannerSecretsAnalyzerBundle, SPRINT_20260908_003 SAC-1): the analyzer’s publish closure plus the offline 2026.01 ruleset under .runtime/plugins/scanner/base/secrets/{,ruleset/}, which the base mount exposes at the Worker default /app/plugins/scanner/base/secrets; ScannerOsAnalyzerHost skips that directory because it carries no manifest.json. Before SAC-1 nothing produced the bundle, so a secrets-enabled Worker failed the stage at load time and reported zero findings (the e2e-analyzer-coverage lane measured 0 of 88 secret-tagged repos in run 17169). 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 enable or tune the disabled-by-default bridge under Scanner:Worker:ScmInfoAvailableTrigger (Enabled, PollInterval, BatchSize). The owner URL and tenant allowlist are configured under Scanner:Worker:ReleaseOrchestratorEvents; see the event consumption guide. Progress is a durable tenant/kind checkpoint, not an operator-supplied 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, appends integrations.registry-image.discovered v1 atomically beside the discovery row, and serves retained stream integrations.registry-images at GET /api/v1/integrations/registry-image-discoveries/events. Scanner.Worker pulls that owner route through its existing authenticated Integrations client, under one P6 lease and a Scanner-owned inbox/checkpoint. It triggers before advancing the checkpoint; replay is safe because the scan id and queue idempotency key are deterministic. Epoch changes reset the cursor, while a retention gap fails closed and directs the operator to the recorded discovery replay procedure. No Scanner process opens the Integrations database. Each (tenant, registryHost, repo, tag, digest) then enters RegistryImageScanTrigger. The trigger:
- Readiness gate (AUTO-002): queries the same sources
SbomReadinessServiceuses (anartifact_bomrow for the digest =ready; otherwise the latestscan_runtime_staterow). It skipsready/scanning/pendingand submitsabsent(and, bounded by the retry policy,failed). - Deterministic scan id + dedupe (AUTO-003):
scanId = SHA-256(seed)[..40]over("stellaops.scanner.registry-image.v1", tenant, registryHost, repoPath, digest), so repeated discovery of the same digest across the on-connect path and the periodic sweep collapses to one scan; itTryCreates thePendingruntime-state row and only enqueues when newly-created or stillPending/Queued, withIdempotencyKey = scanIdso the queue dedupes too. The digest is the natural identity; host + repo disambiguate the same digest mirrored across registries. - Failed-retry policy (AUTO-006): a
faileddigest is re-submitted at mostFailedRetryMaxAttemptstimes and never more often thanFailedRetryMinIntervalsince the last attempt (read fromscan_runtime_state.updated_at/created_at), so a permanently-unscannable image cannot hot-loop the queue;FailureReasonis preserved for the readiness endpoint.
Tune under Scanner:Worker:RegistryImageDiscoveredTrigger (Enabled, PollInterval, BatchSize, LeaseTtl, FailedRetryMaxAttempts, FailedRetryMinInterval). The durable checkpoint replaces the former operator-supplied StartAfterEventId cursor.
Registry discovery poll timeouts are logged, retain the durable checkpoint, and retry on the next configured interval. They do not stop the worker host. The vulnerability-surface feed and scan-level reachability job poller use the same failure handling. Host shutdown cancellation still stops each poll loop.
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 origin | Reachability source | Path |
|---|---|---|
| Built + pushed by Stella, agent-side producer enabled | build-context | computed from the live build context on the agent (Phase 2) |
| Built + pushed by Stella, Phase 1 only | scm-fetch | central source-derived scan via this bridge |
| Built elsewhere, SCM info available | scm-fetch | central source-derived scan via this bridge |
| Built elsewhere, rootfs-only scan | rootfs | rootfs call-graph extraction |
All four land on the same image subject through the same dual-write OCI referrer sink (ReachabilityReferrerSerializer
- the internal-first dual-write coordinator), so the manifest shape and attachment-state machine are byte-identical regardless of origin — only the
org.stellaops.reachability.sourceannotation differs. The build is never failed over a co-production miss; the orchestrator-side degradation ladder (see release-orchestrator execution-plugins.md) falls back to SBOM-only (then nothing) without raising. The witness artifact is intentionally policy-gate-shaped: it carriespolicyGateEvidencefields matching the PolicyGateEvidence request/decision model andreleaseEvidence.componentfields aligned to Release Evidence component records, including a deterministic localcomponentIdwhen the release component ID is not supplied in scan metadata. Derived evidence remains source-bound: a concrete path maps to static-reachable (SR), path confidence is the mean of the path’s explicit, unambiguous graph-edge confidences, and unavailable derived values are omitted. This stage does not manufacture risk scores, uncertainty tiers,SU, or numeric zeroes for computations it did not perform.
The reachability-stack projection follows the same rule. It preserves the call-graph classifier’s actual entrypoint type and source location. A path has a numeric confidence only when every edge supplies a finite value in [0,1]; the layer uses the minimum path confidence to map >=0.8 to High, >=0.5 to Medium, and lower values to Low. For a reachable path, missing path confidence records Low (the explicit insufficient-evidence state). A completed graph search that resolved the sink but found no path remains the covered Medium static “not observed” result. Path-level confidence is omitted from stored and API JSON rather than translated to 1.0 or 0.
This is local Scanner evidence. Durable reachability graph storage (graph-api) and live Release Orchestrator policy-gate consumption are separate Phase 3 proof gates.
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 case | referrer manifest stored | discovery mechanism | extra 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 tag | one sha256-<subject> index tag per attached image |
| Verdict attestation (either registry) | under verdict-<idempotencyKey> tag | Referrers 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:
- Write/ingest endpoint —
POST /api/v1/oci-referrers(OciReferrerIngestEndpoints→OciReferrerIngestService), the HTTP counterpart to the read API.ScansWriteauth, tenant from the request context; the body carries, for one subject digest, each referrer’sartifactType+ base64 layers + annotations. It maps ontoOciReferrerWriteRequestand drives the existingIOciReferrerCoordinator.WriteAsync(publishExternally: false)— the same internal-first dual-write the worker uses, but internal-only (external attach stays the caller’s own concern). This is whatstella sbom attach --stella-uri <scanner>POSTs to, so CI evidence for a GitLab-CR-hosted image lands in Stella’s store regardless of the registry. - Retention sweep —
OciReferrerRetentionJob(configScanner:OciReferrerRetention) prunes age-expired, not-in-use referrers + orphaned manifest blobs. Conservative by construction:Enabled=false+DryRun=trueby default; deletion requires age-expiry on BOTHcreated_at/updated_atAND anIReferrerInUseProbepositiveNo(default probe answersUnknown⇒ keep) AND dry-run off. This is the Stella-controlled, referrer-aware alternative to GitLab CR’s tag-based cleanup/GC (which is not referrer-aware and can orphan thesha256-<subject>index + untagged referrers). Layer/config blob GC is deferred — only orphan manifest blobs reclaim.
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.
- SCM-anchor resolver (
ExternalImageScmAnchorResolver) reads the OCI config-blob labels viaIOciImageInspector.ReadConfigLabelsAsyncand recoverscommit = CI_COMMIT_SHA(plus short-sha/timestamp/title/ref-name). The repo URL is derived from the registry repository path because the image has no OCI source label: mode (a), the offline-safe default, is a config-map (registryRepoPrefix → gitProjectUrl, longest whole-segment prefix wins, remainder appended); mode (b) is an opt-in, absent-by-default GitLab-API auto-resolve (/api/v4/.../registry/repositories/:id → http_url_to_repo) that makes no external call unlessGitLabApiResolveEnabledis set and an API resolver is registered. A missingCI_COMMIT_SHAor an unresolvable repo URL degrades to SBOM-only (logged, never throws). Bound fromScanner:Worker:ExternalImageScm. - On-demand attach entry (
ExternalImageAttachHandler) accepts an external-image coordinate(registryHost, repoPath, digest, tenant)plus the freshly-scanned SBOM (and optional call-graph) products, resolves the anchor, and attaches both referrers — one CycloneDX SBOM referrer and one reachability referrer — on the same subject digest through the internal-first dual-write coordinator (internal always; external iff the per-registrypublishOciMetadataopt-in is on, in which case the fallback tag above is maintained). The reachabilitysourcefor an externally-built image is alwaysscm-fetch, neverbuild-context, and the reachability referrer is attached only when a non-stub graph and a resolved SCM anchor are both present (otherwise the attach is SBOM-only). The recovered commit/repo are also surfaced asorg.opencontainers.image.revision/.sourceannotations on the SBOM referrer so downstream consumers see provenance the source image lacked. The trigger source (a GitLab new-tag poller or registry webhook) is intentionally out of scope; the handler is the reusable entry point.
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_URI → STELLAOPS_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:
| Annotation | Values | Meaning |
|---|---|---|
org.stellaops.payload.encoding | gzip | identity | How the layer-0 bytes are wrapped. |
org.stellaops.payload.format | reachability-evidence-set | callgraph-compact-indexed | cyclonedx | The logical payload format. |
org.stellaops.payload.formatVersion | integer | The 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
- input digests. The full call graph never rides the referrer — it is CAS-only, addressed by the
org.stellaops.reachability.graph_hashannotation (+org.stellaops.reachability.findingscount) — so the payload is bounded by findings count, not graph size (kilobytes at 189k-edge scale). It verifies third-party from the payload bytes alone (ReachabilityEvidenceReferrerVerifier). At build time the set carries zero findings (it still binds graph_hash + bounds to the image); the scan-side evidence pipeline populates findings.
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:
- Resolves the subject digest (pinned digest, an explicit
--digest, or a manifestHEAD), then lists referrers viaOciReferrerReadClient— the native OCI 1.1 Referrers API first, falling back to thesha256-<hex>fallback-tag index on 404/400 (GitLab CE). A missing fallback tag is “no referrers”, not an error. - 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 byCompactCallGraphReader(the exact inverse ofSerializeCompactGzip— gunzip → expand integer indices back to method symbols) and written to--callgraph-out(defaultstella-callgraph.json); anything unknown / Bailed is listed-and-skipped and the command still exits 0. The newreachability-evidence-setreferrer currently rides that bail path (listed with its format annotations, payload skipped); a dedicated inspect decoder overReachabilityEvidenceReferrerSerializer.DeserializeGzipis 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:
readiness:absent,ready, orstale, derived only from the latest durable SBOM.stalemeanslastSuccessfulScanAtis older thanScanner:SbomReadiness:SuccessfulScanMaxAge(default 14 days, matching the Console image-coverage freshness default). A later attempt never refreshes it.scanStatus: the latest persisted attempt aspending,running,succeeded,failed, orcancelled;lastAttemptAtis itsupdated_at. Queue submission persistsPending. A stale-Pending reap persistsFailed, so it remainsfailedand is identified by its bounded timeout reason rather than an invented stored status.lastSuccessfulScanAt: the latestartifact_boms.inserted_at; failed, cancelled, and reaped attempts can never populate or refresh it.lastAttemptFailureReason: a bounded, operator-safe projection for the latest terminal attempt. It remains visible when older successful evidence exists.
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 writes the same bytes as an OCI referrer on the immutable image digest. A Worker with Scanner storage writes through IOciReferrerCoordinator (SbomOciPublishStageExecutor): the internal referrer store (scanner.oci_referrers, scanner.oci_blobs) first, recording the scan id as scan_job_id, then the external registry when the per-registry publishOciMetadata opt-in allows it; these referrers are digest-addressed as described above. A storage-less Worker falls back to ISbomOciPublisher (src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/SbomOciPublisher.cs), which pushes with a sbom-cdx-<sbom sha256> tag. Both carry org.stellaops.* annotations for SBOM digest, image digest, scan job id, and registry-source id. The SBOM export (§4) reads the internally persisted document. 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.
The same stage projects the SBOM into scanner.artifact_boms (ArtifactBomProjectionWriter). The canonical_bom jsonb column holds each component’s trimmed name, version and purl. A component whose trimmed name, version or purl still contains a control character has no valid identity and is left out of the projection; the writer logs the excluded count, and the published SBOM is not changed. PostgreSQL jsonb refuses \u0000 with SQLSTATE 22P05, so before this rule one such component lost the scan its whole artifact_boms row (PCD-816). The two WebService ingestion paths into artifact_boms, the per-scan SBOM submission and the BYOS upload, instead refuse a document that contains U+0000 anywhere with a 400 validation problem whose errors name the JSON Pointer of each location (at most five). Other control characters remain valid in those uploads.
Every published component carries a purl (PCD-820). CycloneDxComposer publishes a component in components[] of the inventory and usage views (JSON, Protobuf and XML) only when its identity carries a purl. Scanner does not synthesize a purl. Every other component of the view is recorded once, ordered by bom-ref, as a stellaops:sbom.excludedComponent property in metadata.properties. These are the analyzer observation summaries (for example observation::deno, observation::ruby and observation::elixir-otp-runtime), identity-less binaries such as the Rust bin:: fallback for a stripped binary and the rust::heuristic:: records, and any other purl-less analyzer output. The property value is a compact JSON object with these fields:
bomRef,name, and when presentversion,group,type(the analyzer’s component type) andscope.properties: the component properties it would have carried incomponents[].dependsOnanddependedOnBy: its dependency edges within the view.reason:purl-missing.
Dependency edges and policy vulnerabilities in the view reference only published components. A view whose components all carry a purl gets no such property, and its bytes are unchanged. The rule exists for the Findings advisory projection, which requires a purl on every CycloneDX component and otherwise projects the whole document as invalid document_identity_missing with no components; the Deno analyzer emits its purl-less summary on every image. A stripped Rust binary has no package identity, so a pkg:generic purl would invent one, could collide with another binary’s canonical purl, and would still match no advisory. The shared scanner-sbom-interop-v1 corpus carries bookworm-full-cohort.json, composed from the real Deno, Elixir, Ruby and Rust analyzers, which the Findings parser projects available.
1) Solution & project layout
The two deployed 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/. Two further projects sit at the module root: the BuildX generator CLI (§7) and the native-format class library StellaOps.Scanner.Analyzers.Native (the Cartographer project was withdrawn, see §1.0). Several Scanner-named libraries live under the repository-wide src/__Libraries/ root because other modules consume them: StellaOps.Scanner.CallGraph, StellaOps.Scanner.ChangeTrace, StellaOps.Scanner.Evidence, StellaOps.Scanner.ProofSpine, StellaOps.Scanner.Registry, StellaOps.Scanner.Reachability.BuildTime and StellaOps.Scanner.RustAst, together with the neutral OCI client library StellaOps.Oci.Storage. Representative layout (not exhaustive; ls src/Scanner/__Libraries is the inventory):
src/Scanner/
├─ StellaOps.Scanner.WebService/ # REST control plane, exports, manifests, proofs (scanner-web)
├─ StellaOps.Scanner.Worker/ # queue consumer; executes the analyzer pipeline (scanner-worker)
├─ StellaOps.Scanner.Sbomer.BuildXPlugin/ # BuildKit generator CLI (§7)
├─ StellaOps.Scanner.Analyzers.Native/ # ELF/PE/Mach-O readers (class library)
└─ __Libraries/
├─ StellaOps.Scanner.Core / .Contracts … # DTOs, evidence, graph nodes, contracts
├─ StellaOps.Scanner.Persistence # sole PostgreSQL migration authority (namespace StellaOps.Scanner.Storage)
├─ StellaOps.Scanner.Storage.Oci # Scanner-owned OCI referrer serialisation + RegistryApiReference
├─ 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.Emit (+ Composition CDX 1.7 JSON/Protobuf/XML) / StellaOps.Scanner.Sarif
├─ StellaOps.Scanner.Diff / .SmartDiff / .Delta # diff + delta-compare surfaces
├─ StellaOps.Scanner.Reachability / .ReachabilityDrift / .Gate / .Triage
├─ StellaOps.Scanner.Manifest / .Explainability / .Sbom.BuildTime
├─ StellaOps.Scanner.ServiceSecurity / .CryptoAnalysis / .AiMlSecurity / .BuildProvenance / .PatchVerification
├─ StellaOps.Scanner.Surface{,.Env,.FS,.Secrets,.Validation} / .VulnSurfaces / .Runtime / .Sources
├─ StellaOps.Scanner.Operations # Doctor checks (doctor.scanner.*)
└─ StellaOps.Scanner.Plugin.{Contracts,Loader,SigningTool}
StellaOps.Scanner.ProofIntegration and several duplicate-source test projects were frozen on 2026-09-11 under src/__Obsoleted/Scanner/ (each carries an AGENTS.md with the ruling); nothing live references them.
Note (was stale): there is no
StellaOps.Scanner.Symbols.Nativeproject, and the call-graph library isStellaOps.Scanner.CallGraph(not.CallGraph.Native). PE and Mach-O native parsers are implemented today inStellaOps.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, updated 2026-09-08):scanner-workerno longer compiles the Native analyzer implementation project directly. The Worker-owned native discovery/execution path consumes Build-ID index and section-hash abstractions fromStellaOps.Scanner.Emit.Native. The host section-hash extractor was a no-op from 2026-06-06 until SPRINT_20260908_003 SAC-3, which added the host-ownedStellaOps.Scanner.Emit.Native.ElfSectionHashExtractor(a second copy of the analyzer project’s parser, pinned identical byElfSectionHashExtractorParityTests) so the Worker again emits.note.gnu.build-idbuild-ids and section evidence (evidence:section:<name>:sha256) for every discovered ELF; the hashed section set binds fromScanner:Analyzers:Native:SectionHashes. A signed native-stage bundle/probe contract is still open.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
ICryptoHashprefix; standalone fallback computation uses SHA-256 and carriessha256:rather than a misleadingblake3:label.
Composer repository provenance (PCD-332)
PHP Composer package identity retains reported repository provenance (PCD-332). The lock reader recognizes the explicit Drupal /8/downloads and Packagist notification endpoints carried by Composer package metadata; matching installed.json metadata can supply missing evidence. Conflicting reports yield ambiguous, while missing, unsupported or unsafe endpoints yield unknown. Source Git URLs, distribution download URLs and the drupal/ vendor name alone do not identify the Composer repository. This is reported metadata, not a cryptographic origin attestation.
The official Drupal origin adds its repository_url qualifier to both component and dependency identities. The standard Packagist origin keeps the default unqualified Composer form. Repository status, URL and evidence source travel as component metadata; unknown origin remains explicit. CycloneDX and SPDX preserve different qualified repositories as separate identities. No repository metadata is fetched during analysis.
PCD-332 source verified against
1e52c13050(2026-10-06). Re-verify:pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Php.Tests/StellaOps.Scanner.Analyzers.Lang.Php.Tests.csproj -Method "*RepositoryIdentity*" -BuildProjectReferences -MaxThreads 1.
Vulnerability surface lookup (PCD-335)
The hub issue-facts adapter retains lastAffected (PCD-334) in the shared consensus input. Winning ranges carry that inclusive end and source-exact exclusive introducedExclusive to the matcher SDK; a declared fixed end remains exclusive. Invalid bound types or blank bounds make the owner lookup unavailable. Surface calculation continues to require a declared fix and never derives one from the last vulnerable version. Declared generic comparison is retained separately from package ecosystem joins; unsupported applicability returns an explicit outcome and fails the reachability stage rather than reporting a clean zero. Ecosystem-specific bound normalization applies only to ranges without a declared comparison override.
PCD-445 source verified against
56357b8682799c029045a161491eabab422373e8(2026-10-08 UTC). Re-verify:pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/StellaOps.Scanner.Worker.Tests.csproj -All -BuildProjectReferences.
PCD-334 source verified against
6d3401cdc5(2026-10-06). Re-verify:pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/StellaOps.Scanner.Worker.Tests.csproj -Method "*LastAffected*" -BuildProjectReferences -MaxThreads 1.
Scanner’s stored surface contains the concrete vulnerable version used for the diff and the declared fixed version. Scan lookup compares the deployed package version within that interval: the vulnerable version is inclusive and the fix is exclusive. It uses the closed StellaOps.VulnMatch.Core SDK’s ecosystem comparator, shared with advisory matching. An exact computed surface takes precedence; overlapping computed ranges prefer the latest start and then the closest fix, with stable ordinal/UUID tie breaking. Tenant, advisory, ecosystem and package filters remain in the owner query. Missing fixes do not expand an exact surface into an open range, and failed computation rows do not supply range coverage.
The Hub feed uses root fixedVersion, an actual declared fix, and does not produce requests from OSV last_affected alone. Feed deduplication requires the exact vulnerable/fixed pair even when scan lookup finds an overlapping range. No applicability contract or surface schema changes.
1.0 Cartographer project (withdrawn)
- The source host was withdrawn with its tests to
src/__Obsoleted/Scanner/StellaOps.Scanner.Cartographer/on 2026-09-27 (PCD-73, CoC 15.4). It mappedPOST /api/v1/cartographer/assemble, which nothing insrc/,docs/ordevops/called. - It was never a deployable after OAR-1 and GRA-9: compose slot 21 is retired (“merged into graph-api Slot 20”), the services-matrix row is commented out, the release bundle has no cartographer service, and the
cartographer.stella-ops.localnetwork alias is served bygraph-api. - The former
src/Cartographer/root no longer exists.
Per-analyzer notes (language analyzers):
docs/modules/scanner/analyzers-java.md— Java/Kotlin (Maven, Gradle, fat archives)docs/modules/scanner/dotnet-analyzer.md— .NET (deps.json, NuGet, packages.lock.json, declared-only)docs/modules/scanner/analyzers-python.md— Python (pip, Poetry, pipenv, conda, editables, vendored)docs/modules/scanner/analyzers-node.md— Node.js (npm, Yarn, pnpm, multi-version locks)docs/modules/scanner/analyzers-bun.md— Bun (bun.lock v1, dev classification, patches)docs/modules/scanner/analyzers-go.md— Go (build info, modules)
Analyzer status (sprint 20260430.012 — pass-2 audit Group F closure):
| Analyzer | Status | Notes |
|---|---|---|
| Java, .NET, Python, Go, Bun, Ruby, Php | Production | Pre-existing. |
| Node.js | Production | F2 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. |
| Deno | Production | F1a 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. |
| Rust | Production | F1b 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++ | Production | Conan, 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. |
| Secrets | Production | F3 closed: SecretFinding.Id fallback now derives a deterministic Guid via HMAC-SHA256(application-pepper, `ruleId |
Cross-analyzer contract (identity safety, evidence locators, container layout):
docs/modules/scanner/language-analyzers-contract.md— PURL vs explicit-key rules, evidence formats, bounded scanning
Semantic entrypoint analysis (Sprint 0411):
docs/modules/scanner/semantic-entrypoint-schema.md— Schema for intent, capabilities, threat vectors, and data boundaries
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:
- Intent Classification — Infers application type (WebServer, Worker, CliTool, Serverless, etc.) from framework detection and entrypoint analysis
- Capability Detection — Identifies system resource access patterns (network, filesystem, database, crypto)
- Threat Vector Inference — Maps capabilities to potential attack vectors with CWE/OWASP references
- Data Boundary Mapping — Tracks data flow boundaries with sensitivity classification
Components:
StellaOps.Scanner.EntryTrace/Semantic/— Core semantic types and orchestratorStellaOps.Scanner.EntryTrace/Semantic/Adapters/— Language-specific adapters (Python, Java, Node, .NET, Go)StellaOps.Scanner.EntryTrace/Semantic/Analysis/— Capability detection, threat inference, boundary mapping
Integration points:
LanguageComponentRecordincludes semantic fields (intent,capabilities[],threatVectors[])richgraph-v1nodes carry semantic attributes viasemantic_*keys- CycloneDX/SPDX SBOMs include
stellaops:semantic.*property extensions
CLI usage: stella scan --semantic <image> enables semantic analysis in output.
1.2 Native reachability upgrades (Nov 2026)
- Stripped-binary pipeline: native analyzers must recover functions even without symbols (prolog patterns, xrefs, PLT/GOT, vtables). Emit a tool-agnostic neutral JSON (NJIF) with functions, CFG/CG, and evidence tags. Keep heuristics deterministic and record toolchain hashes in the scan manifest.
- Synthetic roots: treat
.preinit_array,.init_array, legacy.ctors, and_initas graph entrypoints; add roots for constructors in eachDT_NEEDEDdependency. Tag edges from these roots withphase=loadfor explainers. - Build-id capture: read
.note.gnu.build-idfor every ELF, store hex build-id alongside soname/path, propagate intoSymbolID/code_id, and expose it to SBOM + runtime joiners. If missing, fall back to file hash and mark source accordingly. - PURL-resolved edges: annotate call edges with the callee purl and
symbol_digestso graphs merge with SBOM components. Seedocs/modules/graph/reachability/guides/purl-resolved-edges.mdfor schema rules and acceptance tests. - Symbol hints in evidence: reachability union and richgraph payloads emit
symbol {mangled,demangled,source,confidence}plus optionalcode_block_hashfor stripped/heuristic functions; serializers clamp confidence to [0,1] and uppercasesource(DWARF|PDB|SYM|NONE) for determinism. - Unknowns emission: when symbol -> purl mapping or edge targets remain unresolved, emit structured Unknowns to Signals (see
docs/modules/signals/guides/unknowns-registry.md) instead of dropping evidence. - RichGraph attestation boundary (verified 2026-07-17): the Scanner WebService signs its graph-level RichGraph statement (digest, counts, analyzer metadata, and references) through the operator-configured DSSE key, and chain verification rejects unsigned or tampered statements. The service remains process-local and does not carry individual edge bodies. Edge-bundle DSSE, the proposed
cas://reachability/edges/...layout, and edge-level Rekor publication remain unimplemented design scope rather than current behavior. - Reachability publisher signing boundary (verified 2026-07-18): the separate
ReachabilityWitnessPublisherlibrary fails closed when attestation is enabled withoutIDsseSigningService, before graph/envelope CAS writes. A bare payload hash is never emitted as a DSSE signature. The explicitly signed CAS path remains covered. Its Rekor submission branch was withdrawn on 2026-10-01 (SPRINT_20260928_008 PCD-133) with theAttestorSubmissionClientit depended on, which posted a route no host serves and was registered nowhere; witness envelopes are not submitted to a transparency log.AddReachabilityWitnessAttestation()has no production caller and does not compose ProofChainBinaryMicroWitnessPredicateor the CLIwitnessflow. - Minimal-subgraph publisher signing boundary (verified 2026-07-18): the sibling
ReachabilitySubgraphPublisheralso requiresIDsseSigningServicewhenever enabled and checks that requirement before normalization or subgraph CAS writes. It never substitutesSHA256(statementBytes)for a signature. The extractor/publisher remain portable library components rather than a hosted pipeline: their registration extension has no production caller, and the hosted PoE Worker still binds null graph/entrypoint/vulnerability resolvers. - Deterministic call-graph manifest: capture analyzer versions, feed hashes, toolchain digests, and flags in a manifest stored alongside
richgraph-v1; replaying with the same manifest MUST yield identical node/edge sets and hashes (seedocs/modules/graph/reachability/guides/lead.md).
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:
- Redis stream
scanner:jobs= dispatch channel. On submission,Scanner.WebServicepersists the Pending row (read-model) and then enqueues aScanQueueMessageonto the stream viaQueuePublishingScanCoordinator(a decorator overPersistedScanCoordinator). The message carriesscanId,tenant, andimage.reference/image.digestattributes plus submission metadata; the message is keyed byscanIdfor idempotency. Newly-created submissions are enqueued. An explicit forced rescan also enqueues the existing scan id so the operator request cannot stop at the read-model row; ordinary non-forced re-submissions remain deduplicated. - Postgres
scanner.scan_runtime_state= status read-model. It remains the source-of-truth forGET /scans/{id}status reads.Scanner.Workeris the single writer of post-dispatch lifecycle state; report creation may attach the Scanner-owned report-attestation DTO without changing lifecycle status. - Worker consumption.
QueueBackedScanJobSourceadaptsIScanQueueintoIScanJobSource: it re-claims expired outstanding leases first (ClaimExpiredLeasesAsync, idle threshold =scanner:queue:redis:claimIdleThresholdor the lease duration), then leases fresh messages (LeaseAsync). A fresh backlog cannot starve recovery. On acquire it marks the scanRunning, while a previously persistedSucceededstate is retained.CompleteAsyncpersistsSucceededand its completion event in one transaction before acknowledging the stream lease.PoisonAsyncpersistsFailedbefore dead-lettering the lease.AbandonAsyncreleases processing failures for retry without a terminal write. - Redis retry recovery. Retry backoff retains the original pending delivery; shutdown during that delay leaves it available for expiry recovery. Retry and dead-letter handoffs atomically install the successor entry before acknowledging and deleting the original, on the same Redis node. The handoff verifies the current consumer, retains retry idempotency and its existing expiry, and keeps the original pending delivery when the destination append fails. Scanner status reads continue to show
Runninguntil the replacement worker writes a terminal state; a failed recovery records its reason before dead-lettering. Running write-back is advisory. Terminal persistence errors, including a missing runtime-state row, propagate through the queue adapter. The worker records the error and leaves the outstanding delivery recoverable; it reports no completed or quarantined job and attempts no further disposition for that delivery. Database recovery permits the lease to be reclaimed and the terminal operation retried. A repeated success does not emit another completion event. The configured processing retry budget continues to apply to processing failures. A storage-less worker usesNullScanRuntimeStateWriter. Terminal recovery was verified against4589ac3a3029ca71487fea27fc7dfd7ca9d1ba27on 2026-10-06. Re-verify withTerminalPersistenceRecoveryTestsandWorkerRetryTeststhroughtools/scripts/test-targeted-xunit.ps1 -BuildProjectReferences; the PCD-400 receipt (pruned evidencedocs/implplan/_evidence/20261006-scanner-jobengine-carry/PCD-400.mdat4531aae054) records the exact commands and runtime controls. - Operator failure contract. A digest without a repository (
ScanTargetNotLocatableException), a reference without a configured registry (ScanRegistryNotConfiguredException), or an image absent from the configured registry (ScanImageNotFoundException) is poisoned on the first attempt: the same input/configuration cannot succeed on retry. The queue adapter writesFailedbefore dead-lettering the lease. Other failures, including timeouts and remote-service failures, retain the configured attempt budget. Every terminal failure storesfailure_reasonas a deterministic, bounded human-readable projection with exactly one owner phase (resolve,pull,analyze, orreport) and a closed failure category. The projection never consumes exception messages, type names, stack traces, URIs, credentials, or filesystem paths. Detailed exceptions remain in the existing stage/job structured logs keyed by job and scan identifiers;GET /api/v1/scans/{id}exposes the safe durable projection unchanged. Unknown exceptions use an actionable log-correlation fallback rather than leaking runtime implementation detail. - Stale Pending convergence.
StaleScanReaperHostedServiceruns immediately at worker startup and then atScanner:Worker:StaleScanReaper:PollInterval(default one minute). When enabled, it selects at mostBatchSizerows still exactlyPendingwhoseupdated_atis older than the configuredTimeout(default 30 minutes), then atomically updates each row only whenscan_id,tenant_id, exact status, and stale cutoff still match. The durable reason is bounded and names only the persisted Pending state and timeout budget.scan_runtime_statehas no current-stage or heartbeat column, so the reaper never claims a stage and deliberately leaves everyRunningrow untouched; refreshed Pending rows lose the guarded race and remain active. Repeated sweeps are idempotent.
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: an image scan that names only one fails at pull-layers. Source-checkout and replay jobs carry a raw digest for identity and skip the stage. 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:
Scanner.Workermust have a realIScanJobSourceoutsideDevelopmentandTesting.NullScanJobSourceis a local/test idle fallback only. Production-like startup fails if no queue-backed source is registered, because an idle worker would otherwise look healthy while never processing scans.- When
PoE:Enabled=true,Scanner.Workermust bindStellaOps.Attestor.IDsseSigningServicethrough the configured Signer HTTP provider. Production-like startup rejects missing signer bindings andNullDsseSigningService, and options validation rejects the defaultscanner-signing-2025key id, missing signer URL, missingsha256:<64 hex>Scanner image digest, missing proof-of-entitlement material, and missing DPoP proof. - GitHub code-scanning export is exposed at
POST /api/v1/scans/{scanId}/github/upload-sarifwith companion upload-status and alert-read routes under the same scan prefix. Every route requires the canonical scan read/write policy, and every operation resolves the scan through the tenant-awareIScanCoordinatorbefore calling GitHub; absent and cross-tenant scan ids return404. The accepted upload response andLocationheader are generated from the named status endpoint, so they retain a configured API base path rather than assuming/scansis rooted at/. Whenscanner:gitHubIntegration:enabledis false or no token is resolved, the WebService returns503github_code_scanning_not_configured; it must not synthesize mock SARIF upload ids, statuses, or alert payloads. When enabled, Scanner posts the compressed SARIF payload to the configured GitHub API base URL using the resolved bearer token and repository coordinates. The current direct-upload aggregate is incomplete: persistedPostgresReachabilityQueryServicefindings do not carry severity, affected-version, or source-location data intoScanFindingsSarifExportService, so production upload cannot yet preserve those fields; status/alert reads use one configured default repository and upload accepts request-supplied owner/repo rather than a tenant/scan-owned repository binding. - WebService reachability compute/query/explain placeholders are explicit unsupported services. Until a durable reachability backend is wired, those public reachability endpoints return
501 Not Implementedinstead of fakescheduledjobs or empty result sets. The scan-scoped trace export route is the exception because Console and assurance evidence flows need a machine-readable answer: when runtime trace storage or sensors are absent and no static path witness exists, it returns200 OKwithstatus: sensor_gap, zero trace nodes/edges, explicitsensorGaps[], and a deterministiccontentDigestrather than treating missing runtime observations as proof of non-exposure. If a configured Scanner reachability backend supplies findings and explainable path witnesses, the same route returnsstatus: callable_path_proofwith deterministic static-callnodes[]/edges[], path/finding evidence references, the runtime sensor gap, and a stable digest for authenticated cross-service QA to consume. - Advanced assurance local QA import:
POST /api/v1/qa/fixtures/advanced-assurance-golden/reachability-importaccepts the local golden fixtureseed-manifest.jsonobject asseedManifestand writes Scanner-owned reachability readback state only. It upserts the fixture CVE-to-symbol mapping and stores deterministic results for the callablepayments-apipath plus the package-present/non-callablebilling-workerpath, so/api/v1/reachability/result/{jobId},/api/v1/reachability/mapping/{cveId}, and/api/v1/reachability/vexcan be exercised by the rerun harness. The response explicitly does not claim durable persistence, live runtime sensor observation, Findings ledger writes, or evidence-pack export. - Reachability-evidence results are tenant-partitioned (PCD-864, 2026-10-18). The analyze, result and VEX routes and the QA reachability import take the tenant from the validated claim only (no claim:
400tenant_missing). Analyze and the import store a result under that tenant; the result and VEX routes look up only that tenant’s results. The job ID is still derived from image digest, CVE and purl, so another tenant can compute it, but under its own tenant it reads404and its own analysis or import stores a separate result instead of replacing the first tenant’s. The store stays an in-process cache of at most 1024 results shared by all tenants. - Advanced assurance SBOM QA import:
POST /api/v1/qa/fixtures/advanced-assurance-golden/sbom-importaccepts the local golden fixture body asseedManifestand writes Scanner-owned fixture readback state for ASSURE-001/002/003. The companion readback and check routes prove usage-SBOM parent chain, runtime digest binding, exact observed-image binding, and fail-closed tamper/mismatched-image handling. The production default store is PostgreSQL tablescanner.advanced_assurance_sbom_fixtures, auto-migrated on startup; in-memory storage is allowed only as an explicit test override. The route is a QA fixture surface and explicitly does not claim live signed archive export, cryptographic signature verification, Findings ledger rows, or EvidenceLocker pack export. The importer writes one globaladvanced-assurance-goldenrecord and constructs its two parent references from the supplied package pair. An identical import preserves the stored row and its update timestamp; changed content replaces it. Invalid fixture IDs and invalid runtime digest bindings leave the retained record intact. Other historical keys accepted by the internal store are preserved, so the current importer’s one-slot behavior is not a table-wide population or payload-repetition bound. The duplicated parent-reference representation and broader historical custody remain open data-identity review items; this change adds no deletion or migration. - WebService no longer declares
/api/v1/registries/images/searchor/api/v1/registries/images/digests. Platform owns the real registry-backed contract; Scanner’s former handlers returned only501 Not Implementedand created a duplicate declaration in the estate-wide published-endpoint namespace (SPRINT_20260809_001 RAR-11).
Runtime form-factor: two deployables
- Scanner.WebService (stateless REST)
- Scanner.Worker (N replicas; queue-driven)
2) External dependencies
- OCI registry with Referrers API (discover attached SBOMs/signatures); registries without it use the fallback-tag scheme above.
- Configured object storage for SBOM artifacts. The release bundle runs SeaweedFS (compose service
rustfs, aliass3.stella-ops.local:8333) and Scanner uses its S3-compatible endpoint with SigV4 access-key/secret-key credentials (bucketscanner-artifacts, surface bucketsurface-cache); S3 is the protocol/client, not a second store. The legacy native RustFS driver remains available only for its documented keyed-object API. S3 Object Lock or native RustFS retention headers protect immutable artifacts. - PostgreSQL: the family-owned database
stellaops_scanner, schemascanner, reached only throughSTELLAOPS_POSTGRES_SCANNER_CONNECTION(§3.2). - Valkey for the scan queue (
scanner:jobs, consumer groupscanner-workers), report events and Router messaging. The queue abstraction also has a NATS transport (src/Scanner/__Libraries/StellaOps.Scanner.Queue/Nats/). - Authority (on-prem OIDC) for OpToks (DPoP/mTLS);
scanner-webis a resource server,scanner-workeruses thestellaops-scanner-workerclient-credentials client. - Router (
router.stella-ops.local):scanner-webregisters as Router microservicescannerover Valkey messaging. - Vulnerabilities hub (
vulnerabilities.stella-ops.local): advisory matching, affected-symbol reads and the EPSS source (Epss__Source: hub) for both Scanner hosts. - Integrations (
integrations.stella-ops.local): SCM and registry credential resolution and the registry image-discovery stream consumed by the Worker. - Release Orchestrator (
release-orchestrator.stella-ops.local): release-evidence publication from the Worker reachability stage. - Evidence / Attestor (
evidence.stella-ops.local:8080): scan-result DSSE submission (Scanner:ScanAttestation:AttestorBaseUrl); Signer for PoE DSSE when enabled. - Timeline (
timeline.stella-ops.local): audit emission fromscanner-web.
3) Contracts & data model
3.1 Evidence-first component model
Nodes
Image,Layer,FileComponent(purl?,name,version?,type,id— may bebin:{sha256})Executable(ELF/PE/Mach-O),Library(native or managed),EntryScript(shell/launcher)
Edges (all carry Evidence)
contains(Image|Layer → File)installs(PackageDB → Component)(OS database row)declares(InstalledMetadata → Component)(dist-info, pom.properties, deps.json…)links_to(Executable → Library)(ELFDT_NEEDED, PE imports)calls(EntryScript → Program)(file:line from shell AST)attests(Rekor → Component|Image)(SBOM/predicate binding)bound_from_attestation(Component_attested → Component_observed)(hash equality proof)
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:
- found / declared — is the component present in the SBOM at all? (SBOM-presence; the inventory tier.)
- 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.)
- 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:
| Scope | Meaning | In deployed-set? |
|---|---|---|
Runtime | Required at production runtime (“prod”). | Yes |
Bundled | Vendored inside another package’s tarball; ships with its parent. | Yes (iff parent deployed) |
Provided | Supplied by the runtime/container at execution (maven provided, servlet APIs). | Yes |
Optional | Installed only if the platform allows; may be absent at runtime. | Yes (fail-safe) |
Peer | Expected to be provided by the consumer; not vendored by this package. | Yes (fail-safe) |
Build | Build-time tooling consumed during compile, not shipped (bundlers, transpilers, codegen). | No |
Test | Test frameworks / fixtures only. | No |
Dev | Development-only (linters, formatters, generators). | No |
Unknown | Could 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:
- Seed roots. The synthetic project root is
Runtime; each root edge carries its section scope (dependencies=Runtime,devDependencies=Dev,optionalDependencies=Optional,peerDependencies=Peer). - 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 toRuntime— prod presence is contagious, the inverse of the “transitive-via-dev” trap. - 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. - 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). - Severed-edge floor (R2). A node reachable only through an unresolvable
link:/workspace:/peer path floors atUnknown(deployed) and is markedscope.resolution = partial-graph. - Orphans → Unknown (deployed-side fail-safe).
- 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:
- CycloneDX
component.scope— mapped from the effective scope:{runtime, bundled, provided}→required;{optional, peer}→optional;{dev, test, build}→excluded;unknown/absent → no claim (the consumer fail-safe re-applies). A proven non-deployed scope is never silently re-promoted. - CycloneDX
dependencies[]— the per-componentDependsOnchild keys (inResolveIdentityKeyform) populate the top-level dependency graph;SpdxComposerlights up itsrelationships/dependsOnfrom the same field. Both were already wired; scope resolution merely feeds them. stellaops.scope.{effective, declared, deployed, resolution}properties — the precise per-component scope view, since CycloneDXscope(required/optional/excluded) is too coarse. See SBOM Scope Property Registry v1 for the value domains.stellaops.scope.deployedis the single bit downstream gates key off.
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)
Current persistence lives in StellaOps.Scanner.Persistence on the family-owned physical database stellaops_scanner, resolved only from STELLAOPS_POSTGRES_SCANNER_CONNECTION. Scanner Web is the single migration authority through AddScannerPersistence (startup migrations, ledger scanner.schema_migrations, module name Scanner.Persistence); Scanner Worker registers the same library through AddScannerStorage and therefore shares the database without gaining a second migration authority. The configuration key Postgres:Scanner:ConnectionString is the only override; generic connection variables are never consulted and a missing owned connection fails startup. The embedded, forward-only chain is ScannerSchemaTopology.OwnedMigrations (src/Scanner/__Libraries/StellaOps.Scanner.Persistence/Migrations/): the consolidated baseline 001, the EPSS removal 002, the artifact-BOM tenant qualification 003, the partition runway repair 004, and the retention convergence set 005-010 (audit/cache retention, retirement of unused tables, record disposal boundaries, observation/response retention, captured record classes and projection boundaries). Source census at 4b396e97d0 (2026-10-06) also includes 011_scan_evidence_image_platform.sql and 012_scan_manifest_owned_inputs.sql; the latter corrects manifest input-column ownership names while retaining existing hashes and signed bytes (see the scan-manifest contract below). 013_tenant_qualify_witnesses.sql (CCP-059) adds the owning tenant to scanner.witnesses and 014_witness_scan_id_text.sql (CCP-060) makes its scan_id the Scanner scan id text (see the witness bullets under 4.1.1). The prerequisite 012a_preserve_unattributed_witnesses.sql (CCP-075) preserves unattributed historical witnesses and verification children in immutable Scanner custody before either migration runs. A nonempty transfer requires approval of the exact preflight manifest through the migration-only path; normal startup refuses it. See witness custody for the original field/digest contract, operator resolution and coordinated forward recovery. Tables use snake_case columns. This source census does not verify a deployed migration ledger. Migration 015_capacity_probe_receipt.sql (DIP-17) adds the owner-local diagnostic receipt; ScannerSchemaTopology includes both that migration and its table in the exact Doctor census. Re-verify with ls src/Scanner/__Libraries/StellaOps.Scanner.Persistence/Migrations/ and ScannerSchemaTopology.OwnedMigrations / ScannerSchemaTopology.DeclaredTables.
Representative catalog tables (all in the scanner schema; the executable inventory is ScannerSchemaTopology.DeclaredTables):
artifacts (id, type, format, media_type, bytes_sha256, size_bytes, immutable, ref_count, rekor jsonb, ttl_class, created_at_utc, updated_at_utc)images (image_digest PK, repository, tag?, architecture?, created_at_utc, last_seen_at_utc)layers (layer_digest PK, media_type, size_bytes, created_at_utc, last_seen_at_utc)links (id PK, from_type, from_digest, artifact_id → artifacts(id))// image/layer → artifactjobs (id PK, kind, state job_state, args jsonb, created_at_utc, started_at_utc, completed_at_utc, heartbeat_at_utc, error)lifecycle_rules (id PK, artifact_id → artifacts(id), class, expires_at_utc, created_at_utc)scans (scan_id UUID PK, created_at_utc)— scalar anchor used by the manifest and proof stores. It is distinct from the tenant-qualifiedscan_runtime_stateand the normal submission’s 40-character identifier. The manifest/proof parent insert preserves the original row on conflict; it is a separate statement from the child write. Explicit deletion cascades to manifests and proofs. Frame evidence deliberately has no scan foreign key and survives that deletion; no automatic parent disposal or reconstruction producer is implied.entry_trace,ruby_packagesandbun_packageshave their own scan-keyed decoded inventory contracts.
GET /api/v1/reachability/{scanId}/stack/frames and GET /api/v1/sbom/{scanId}/evidence first ask the existing scan coordinator for a record owned by the selected tenant. Missing or foreign ownership returns the existing 404 response before frame or projection reads. Canonical global-admin tenant selection follows the same coordinator contract as scan-status reads. The frame route still requires a UUID and a scalar anchor; the evidence route supports text scan IDs and can read owned frame evidence without that scalar anchor. Retained UUID frames without a runtime owner row are refused. This adds no UUID-to-text mapper, evidence producer or deletion.
Evidence projections and signed-SBOM material suppress a database update only when every normalized mutable stored value is equal, including observation timestamps and any signed-at timestamp. Comparison is NULL-safe after each store’s existing normalization: signed-material blank optional strings and empty optional byte arrays still become NULL, while a projection’s empty platform string remains distinct from NULL. A newer observation remains a write and remains visible through projection freshness. Signed-material creation time is insert-only; proposing another creation time alone does not replace the row. The Worker evidence publisher supplies its current observation time, and the verified signed-material submission path uses the store’s current time when timestamps are omitted, so equal content received later is not generally a no-op. This storage replay guard does not change producer, tenant, verification, archive, payload-limit or disposal contracts.
On hosts with persistence configured, each SBOM evidence request reads the current stored projection, so owner row replacements appear on the next authorized read.
New WebService submissions derive their 40-character scan IDs from versioned, length-prefixed request fields. Metadata separators cannot merge different request tuples. Existing trimming, case handling and metadata ordering remain unchanged. Previously issued IDs and their stored rows and links remain readable. The first resubmission of an old request under the new format creates a new scan; later repeats use the new identity. Existing records are not remapped, and the service does not fall back to an ambiguous old identity.
oci_blobs is a tenant/digest catalog containing media type, length, object key, manifest flag and creation time; payload bytes live in IArtifactObjectStore. The local sink hashes and writes the bytes before cataloging them, and the OCI reader resolves the tenant/digest before retrieving the object. These steps are separate operations. Metadata conflict updates are unconditional, so a repeated write is not a no-op guarantee. Orphan cleanup selects only unreferenced manifest rows, then removes backing objects separately; layer/config rows are excluded. This catalog classification does not certify historical metadata, shared-object disposal or live role isolation. The repeated oci_referrers.annotations_json document has a separate identity/budget obligation.
The schema is larger than this representative catalog. Its exact current topology is the declared table inventory plus the forward-only migrations above. Migration 002 removes the mishomed EPSS tables (EPSS belongs to Vulnerabilities), and Scanner Web converges the family-owned eventing schema needed for the tenant completion feed. The legacy Storage and Triage migration ledgers are listed in ScannerSchemaTopology.LegacyMigrations only so the Doctor can detect a database that still carries them; they are not migration authorities.
Triage persistence verification. TriagePostgresFixture uses the supported shared PostgresIntegrationFixture test infrastructure and an isolated real PostgreSQL database. It selects ScannerPersistenceExtensions for both the migration assembly and module name, and applies the complete owned Startup chain in the canonical scanner schema. TriageFixtureMigrationTests reads the real migration ledger, checks every owned migration, and rejects retired ledger entries; EF EnsureCreated cannot substitute for that proof. Verified against source commit 3c68c0f01a (2026-10-04, PCD-361). Re-verify with pwsh -NoProfile -File tools/scripts/test-targeted-xunit.ps1 -Project src/Scanner/__Tests/StellaOps.Scanner.Triage.Tests/StellaOps.Scanner.Triage.Tests.csproj -All -BuildProjectReferences. The adjacent migration-only upgrade fixture executes the real embedded baseline as the Scanner role, then verifies the complete forward chain, exact tenant attribution and second-run idempotency. Verified against 90de03b696; re-verify with the same runner and -Project src/Scanner/__Tests/StellaOps.Scanner.Persistence.Tests/StellaOps.Scanner.Persistence.Tests.csproj -All -BuildProjectReferences.
Retention (RFC-1, RFC-19). Every active Scanner table carries a P13 retention class; the classification and the running cleanup are described in operations/retention-classification.md. Two hosted services in scanner-web run daily: ScannerAuditRetentionService (src/Scanner/StellaOps.Scanner.WebService/Retention/) calls scanner.prune_scan_audit(), scanner.prune_reach_cache() and scanner.prune_observations_and_responses(); ScannerPartitionRunwayService keeps the scanner.artifact_boms monthly partitions ahead of the current month and then drops children older than ScannerSchemaTopology.ArtifactBomRetentionMonths (12) through scanner.drop_artifact_boms_partitions_older_than(12, false). A failed pass is logged and retried on the next pass (over-retention, never over-deletion). See operations/sbom-hot-lookup-operations.mdfor the artifact-BOM window.
Doctor checks. scanner-web maps the doctor-check/v1 read surface (MapServiceDoctorEndpoints("scanner-web"), policy ScannerPolicies.DoctorHealth bound to StellaOpsScopes.OpsHealth) and registers the Scanner checks from src/Scanner/__Libraries/StellaOps.Scanner.Operations/Doctor/ScannerDoctorChecks.cs: doctor.scanner.database.own-database-cutover, doctor.scanner.schema.table-census, doctor.scanner.storage.epss-mishomed-footprint, doctor.scanner.retention.partition-runway and doctor.scanner.retention.artifact-boms-window.
DIP-17 adds doctor.scanner.capacity.budget with an owner-pool probe and a bounded local receipt; see capacity monitoring and its limits. It runs from the embedded register and local PostgreSQL data without an external-network dependency, including on disconnected installations. It does not automatically intercept database writers.
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-10-04): the WebService registers
PostgresMaterialRiskChangeRepository,ManifestBackedScanMetadataRepository, and the Smart-Diffscans/{scanId}/changesandscans/{scanId}/sarifroutes. It does not register or callMaterialRiskChangeDetector, which has no production caller in Scanner, so the material-change table and routes are a retrieval surface, not a live automatic scan-completion pipeline. The VEX-candidate half (VexCandidateEmitter,PostgresVexCandidateStoreand the fivevex-candidates/candidatesroutes) was withdrawn on 2026-10-04 (SPRINT_20260930_002 PCD-222) tosrc/__Obsoleted/Scanner/**/PCD-222/: no host constructed the emitter, so the store had no writer and the routes could only return empty lists. The SARIF route therefore carries no VEX-candidate results. Thescanner.vex_candidatestable remains (forward-only migrations) with no Scanner reader or writer.
3.3 Scanner artifact object-store layout
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)
The legacy native RustFS client exposes the deterministic keyed-object API PUT|GET|DELETE /api/v1/buckets/{bucket}/objects/{key}. Scanner tags immutable native uploads with X-RustFS-Immutable: true and, when retention applies, X-RustFS-Retain-Seconds: <ttlSeconds>; additional headers come from scanner.artifactStore.headers. That native contract has no evidenced list/size route. It therefore cannot measure a prefix and must report scanner-cas-measurement-unavailable rather than a false zero.
The supported production SeaweedFS-over-S3 path implements real prefix-byte measurement through paginated ListObjectsV2 with the existing SigV4 access-key/secret-key contract. Scanner-web registers that measurement as owned state bucket:scanner-artifacts; both Scanner roles receive a P7 pre-write capacity decorator from the shared storage composition. The guard requires the logical budget Doctor:State:bucket:scanner-artifacts:BudgetBytes to be a positive integer, pauses on any measurement/configuration/length uncertainty, and points failures to the Scanner CAS capacity guard procedure. Its measure -> compare -> write sequence is not an atomic cross-replica reservation and must not be described as a hard global quota.
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). No /score/{scanId}/* alias exists.
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
that scan's findings from the reachability query. 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.
**Findings source (DAY-15, SPRINT_20260925_004).** The reachability query, and
therefore `reachability/findings`, this report and `exports/sarif`, returns one
finding per exact (vulnerability, purl) pair from two Scanner-owned sources:
- the persisted public reachability verdicts for the scan's image digest
(symbol-level evidence: `reachable:proven`, `reachable:likely`, `not-observed`);
- the hub-backed v2 matches the worker persisted for the scan in
`scanner.scan_evidence_projections.reachability_findings`, including matches the
reachability policy filtered. A match without a verdict carries no path witness
and no coverage statement, so it is reported as `status = unknown`,
`latticeState = unknown`, `confidence = 0`, with the consensus severity and
affected range the matcher recorded. It is never omitted and never reported as
unreachable.
For a tenant-visible scan, `exports/sarif` serializes a valid SARIF 2.1.0 run even
when the findings query returns an empty list. Its `results` array is empty and
the route returns `200`; an unavailable findings source still returns null.
This lets generated CI jobs export scans that contain no findings. SBOM export
reads the inventory the worker published as an OCI referrer on the image digest;
request `?format=cyclonedx` for CycloneDX JSON (the default format is SPDX).
Absent data is an expected control-plane outcome: unknown artifacts,
cross-tenant artifacts, scans with neither verdicts nor persisted matches, 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 /byos-sboms, GET /byos-sboms/{sbomId} # see byos-ingestion.md
# ProofSpine (ProofSpineEndpoints.cs)
GET /spines/{spineId} GET /scans/{scanId}/spines
# 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
# OfflineKit Scanner-owned artifact-BOM exchange (absolute internal routes)
GET /api/internal/v1/offlinekit/artifact-boms # scanner:offlinekit:artifact-boms
POST /api/internal/v1/offlinekit/artifact-boms/activate # scanner:offlinekit:artifact-boms
# Health / build (not under the API base path)
GET /healthz | /readyz | /buildinfo.json
SBOM export source. GET /scans/{scanId}/exports/sbom (and the signed-SBOM archive built on it) reads the CycloneDX SBOM the worker persisted for the scan, never a per-process cache. The worker’s SBOM publish stage always writes that document first to the internal OCI referrer store (scanner.oci_referrers + scanner.oci_blobs, bytes in the artifact object store), keyed by tenant and image digest and carrying the scan id as scan_job_id. The export selects the scan’s own SBOM referrer for the scan’s image digest; when a later scan of the same digest replaced that row, it takes the newest SBOM for the digest. The formats render that one inventory:
format | Response |
|---|---|
cyclonedx | The persisted CycloneDX bytes, unchanged (Accept: application/vnd.cyclonedx+xml converts them to CycloneDX 1.7 XML). |
spdx2 (default) | SPDX 2.3 JSON rendered from the persisted components: one package per component with its purl, the image as the described CONTAINER package. Deterministic: created is the SBOM’s own metadata.timestamp. |
spdx3 | SPDX 3.0.1 JSON-LD composed from the persisted components that carry a purl, grouped by stellaops:firstLayerDigest. |
A scan without a resolved image digest or without a persisted SBOM returns 404 with the translated problem title “No SBOM data available”.
Per-layer SBOM source. GET /scans/{scanId}/layers, GET /scans/{scanId}/layers/{layerDigest}/sbom, GET /scans/{scanId}/composition-recipe and POST /scans/{scanId}/composition-recipe/verify (used by stella scan layers, stella scan layer-sbom and stella scan recipe) read the per-layer inventory the worker persisted, never a per-process cache. The worker’s compose stage writes the analysis layer fragments as the layer.fragments artifact of its surface manifest (bytes in the artifact object store; the manifest registered in scanner.artifacts and linked to the image digest in scanner.links). The routes select the manifest the scan produced for its image digest, or the newest manifest for that digest when the scan’s own is absent. Fragments of the same layer (OS and language analyzers each contribute one) are merged by component identity, and layers keep the order in which the worker recorded them. Layer SBOMs (CycloneDX 1.7, SPDX 3.0.1) are rendered from those fragments at the manifest’s generation time, so repeated reads return identical bytes. The recipe’s createdAt is that time, generatorVersion is the worker version recorded in the manifest, and aggregatedSbomDigests.cyclonedx is the SHA-256 of the image SBOM the export serves (empty when the worker persisted none). Verification reports invalid when the stored fragment bytes no longer hash to the digest the manifest recorded. A scan without a persisted manifest lists no layers and returns 404 for the layer SBOM and the recipe.
Q-25 ownership cut (owner 2026-08-27): Scanner is not the generic offline-kit HTTP owner. OfflineKit owns
/api/offlinekit/v1/{import,status,manifest,validate}, verified carrier handling, quarantine/CAS, bundle lifecycle and its import ledger. Carrier-levelverifiedcustody and manifest inventory do not imply whole-bundle activation. For the exactscanner/artifact-boms.v1.jsondeclaration, OfflineKit extracts and size/digest/schema-verifies the part and calls Scanner’s active bounded owner API; Scanner alone persists it. Both Scanner generic route groups retire in the repository source cut. The settlement performed no live deployment or data cutover.
Q-25 also removes Scanner’s route-only MapObservabilityEndpoints and its sole backing OfflineKitMetricsStore; /metrics is therefore not a current Scanner route. This is a narrow retirement of the duplicate import plane’s metric surface, not removal of Scanner’s other instrumentation, meters, logs, traces or health checks.
Beyond the core surface above, Program.cs also maps these endpoint groups (see endpoint-registration-matrix.md for the full inventory and scopes): SBOM readiness/attachments, sources + SCM bulk import (/sources, /scm/...), webhooks (quarantined, 501), OCI referrer ingest (/oci-referrers) and the registry-v2 read surface (/v2/...), 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, tenant settings/analyzers/performance, scan-policy CRUD, policy preview (feature-gated), scan-attestation JWKS, runtime ingest, reachability stack + evidence, advanced-assurance QA fixtures, function maps (/api/v1/function-maps), slices (/api/slices), and the nested scan maps (layer SBOM, call graph, approvals, evidence, manifests, GitHub code-scanning, scan-completed stream, digest detail). MVC controllers add the triage gating/evidence routes, /api/v1/findings/{findingId}/evidence and the development-fixture /api/v1/vulnerabilities routes; the VEX-reachability filter and the VEX gate read endpoints were withdrawn by PCD-222 (§5.5.6). MapValidationEndpoints, MapFidelityEndpoints and MapDeltaEvidenceEndpoints exist in source but are not registered. 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: a normalized, sorted list of analyzer IDs used to suppress matching admitted signed analyzers in Scanner Worker. The settings writer validates identifier syntax; it does not require each submitted ID to name a currently admitted analyzer. 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 attempt to emit a Timeline audit event containing actor, tenant, timestamp, and before/after analyzer IDs and ETags. Audit publication is best effort and does not share the settings transaction. 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.
The stored record contains a tenant key, analyzer identifier tokens, a version ETag and update actor/time. The owner API accepts at most 256 submitted identifiers, each at most 128 ASCII identifier characters, then trims, lowercases, sorts and deduplicates them. The database separately limits array cardinality to 256. These are identifier controls, not a stored document or a bound on total tenants. Repeating equal settings currently updates actor/time and attempts another audit event; the record classification does not assert a no-op replay. The worker suppression applies to admitted signed OS/language analyzers; native-engine configuration remains separate. No time-based disposal is configured.
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
- Runtime endpoints:
GET /api/v1/scans/{id}/manifestGET /api/v1/scans/{id}/proofsGET /api/v1/scans/{id}/proofs/{rootHash}
- SBOM upload records are durable runtime state and are stored in PostgreSQL table
scanner.sbom_uploads;(tenant_id, sbom_id)is the identity boundary, and reads/upserts must include the validated tenant so identical SBOM content cannot overwrite or disclose another tenant’s metadata. The WebService must not rebind this path to an in-memory upload store. - Tenant binding (PCD-864, 2026-10-18):
scanner.scan_manifestandscanner.proof_bundlecarry no tenant column. The manifest and proof routes, and the score replay, bundle, verify and history routes that read the same rows, first resolve the scan through the tenant-awareIScanCoordinator(the scan’s persisted owner inscanner.scan_runtime_state). A scan of another tenant, an unknown scan and a scan without a persisted owner all answer404with no body before any manifest, proof or replay state is read or written. Score verify reads only the scan’s storedproof_bundlerecord for the expected root hash; the request names no bundle location (PCD-879, 2026-10-19).
4.1.1 BYOS upload tenant key
The consolidated baseline (001_scanner_consolidated_baseline.sql) creates scanner.sbom_uploads with primary key pk_sbom_uploads (tenant_id, sbom_id) and creates scanner.tenant_settings, so a database converged by the current chain has tenant-isolated BYOS uploads from its first start. The earlier Storage-library migration 011_sprint20260719_byos_upload_tenant_isolation.sql and its image-by-image cutover procedure belong to the retired legacy ledger and do not apply to the current migration authority. Scanner migrations remain forward-only; a rollback requires a pre-migration PostgreSQL snapshot plus the coordinated old image pair.
- Policy snapshot and policy audit state are durable runtime state and are stored through the Policy PostgreSQL backend; the WebService must not rebind these paths to in-memory policy repositories.
- The live manifest/proof and score-replay retrieval path is backed by PostgreSQL tables
scanner.scan_manifestandscanner.proof_bundle. StellaOps.Scanner.WebServicemust not bind these runtime paths toInMemoryScanManifestRepository,InMemoryProofBundleRepository,TestManifestRepository, orTestProofBundleRepository.- When singleton replay services need manifest/proof access, they must resolve the scoped PostgreSQL repositories through an adapter rather than bypassing persisted storage.
ScoreReplayServiceserializes each case-insensitive scan ID within one service instance through manifest lookup, signature validation, scoring and proof/history writes. A short registry lock protects semaphore membership and caller counts; it is not held during replay I/O. Every active or waiting caller owns a reference, including callers whose wait is canceled. Only an acquired semaphore is released. The last departing caller removes and disposes its entry, so idle unknown/refused/failed replay IDs do not accumulate lock entries. Entries are bounded by distinct scan IDs with in-flight callers, and different scans remain independent. The default measurement refusal still precedes proof/history writes; existing process-local history and canonical-binding metadata retain their current lifetime. Re-verify PCD-406 withScoreReplayLockLifecycleTests,ScoreReplayUnavailableInputsTestsandScoreReplayEndpointsTeststhroughtools/scripts/test-targeted-xunit.ps1.- PCD-406 lock lifetime was verified against
34a66ad1c36412298f962eedb5ffef69a748825eon 2026-10-06; acceptance evidence (pruned evidencedocs/implplan/_evidence/20261006-scanner-replay-lock-lifecycle/PCD-406.mdatac2b8d47e7) records the red-first controls and the isolated owner HTTP/storage/signing path. - Scanner WebService must not synthesize
sha256:unknownfor production-looking digest fields. Triage finding grouping omits unknown-placeholder artifact digests, slice generation fails closed with409 Conflictuntil a persisted manifest or scan metadata supplies a trusted artifact digest, and the in-memory manifest repository returns no manifest when its scan snapshot lacks a trusted target digest. - Signed SBOM archive export is fail-closed until Scanner has verified DSSE envelope bytes against the SBOM payload and real local signing certificate material from the signing/attestation path. The archive builder rejects payload mismatch, forged signatures, placeholder certificates, and JWS-looking tokens before packaging.
- Scanner.WebService resolves signed-SBOM archive material from durable PostgreSQL table
scanner.scan_signed_sbom_materials, keyed by(scan_id, sbom_sha256, sbom_format), before archive packaging. Missing material returns409 signed_material_unavailable; persisted material that fails local DSSE/Fulcio/Rekor/tile/sparse/revocation checks returns422 signed_material_invalid. The endpoint must not synthesize DSSE envelopes, certificates, Rekor receipts, tile snapshots, or sparse proof data at export time. - Signed-SBOM archive metadata never emits a placeholder Scanner image digest.
metadata.jsonincludesstellaOps.scannerDigestonly whenscanner:archiveMetadata:scannerImageDigestis configured with a realsha256:<64 hex>runtime image digest; otherwise the field is omitted. WhenincludeSchemas=true, archive-level schemas are loaded from embedded WebService resources and bundled asschemas/manifest.v1.schema.jsonandschemas/metadata.v1.schema.jsonfor offline validation. - Signing and attestation producers hand signed-SBOM material to
POST /api/v1/scans/{scanId}/exports/signed-sbom-materialunder thescanner.signed-sbom-material.writeAuthority scope. Scanner exports the current SBOM bytes for the requested scan/format, verifies the submitted DSSE/certificate/Rekor/tile/sparse/revocation material through the same archive builder used by export, and only then upsertsscanner.scan_signed_sbom_materials. Invalid material returns422 signed_material_invalidand is not stored. - Path witness verification is also fail-closed. A stored DSSE-looking envelope with a
signaturesarray is not enough to returnvalid; withoutScanner:OfflineKit:TrustRootDirectory, the API records the attempt asunverified. - Witness listing is filter-scoped.
GET /api/v1/witnesses?scanId=<UUID>reads witnesses for that scan. The<UUID>placeholder is the form before CCP-060:scanIdis now the Scanner scan id (scanner.scan_runtime_state.scan_id, 40 lower-case hex characters for a submitted scan), and migration014_witness_scan_id_text.sqlturned the UUID column into text, keeping each stored UUID as its text. A present but blankscanIdreturns HTTP 400Invalid scan identifierrather than falling through to the unfiltered 200/empty response. - Witnesses are stored and read per tenant (CCP-059, migration
013_tenant_qualify_witnesses.sql).scanner.witnesses.tenant_idis NOT NULL with no default, and a witness hash is unique per tenant. Every witness route resolves the tenant from the validated claim and rejects a request without one (HTTP 400tenant_missing); a witness id, hash, scan id, CVE or graph hash of another tenant returns an empty list or 404, and verification records nothing for it. Before this change the table had no tenant column and the repository read under one fixed session tenant, so a scan UUID of another tenant listed its witnesses. - Witnesses are produced by the scan path (CCP-060). The
reachability-evidencestage’s evidence job builds onePathWitnessper entrypoint-to-sink path (ReachabilityEvidenceJobResult.Witnesses, the witnesses the public verdict references), and thepublish-evidencestage (ReachabilityWitnessStageExecutorthroughReachabilityWitnessRecorder) stores them inscanner.witnessesfor the tenantscanner.scan_runtime_staterecords for the scan and under the scan’s own id; the on-demand scan-level job (compute-reachability) stores the witnesses of its completed child jobs the same way. Rows hold the canonical DSSE payload and a content hash over it and are unsigned (verify reportsunsigned); recording is idempotent per scan bywitness_id. A scan without a recorded tenant stores no witness; a storage failure fails the stage or the on-demand job. Before CCP-060 no production code wrotescanner.witnesses. The producer rules are indocs/contracts/witness-v1.md(Producer). - Witness detail has a two-layer wire contract.
GET /api/v1/witnesses/{id}emits the persisted record envelope (witnessId, hashes/schema/type, scan/run ids, timestamps, signer/entrypoint/CVE metadata, signed state) and preserves the stored domain document underpayload; it does not flatten arbitrary payload members into the envelope. Consumers must explicitly map that payload and accept both camel-case and the persisted snake-case spellings used by Scanner producers. A missing or malformed optional call-path, gate, runtime, confidence, or reachability member is unknown/absent for that panel; it must not invalidate the envelope or imply a negative result. Verification responses additionally expose the key id actually returned by the verifier. - When
Scanner:OfflineKit:TrustRootDirectorypoints at local PEM trust roots or an offline bundle containing*.pemroots, Scanner verifies witness DSSE envelopes through the sharedStellaOps.Attestationlocal trust-root verifier. Forged or mismatched signatures returninvalid; only a cryptographically verified DSSE result may returnvalid. - Secrets rule bundle Rekor enforcement is also fail-closed. If bundle verification is configured to require a Rekor proof but no real verifier is wired, a present Rekor log id is insufficient and the bundle is invalid rather than “verified with warnings”.
- Positive signed SBOM fixtures require actual DSSE signature bytes over the SBOM payload and local certificate/public-key verification. For
SignatureType=keylessor bundled Fulcio material, Scanner also validates the signing certificate chain to a local Fulcio root, requires a non-CA signing leaf withdigitalSignature, and checks the Fulcio OIDC issuer extension when an issuer policy is supplied. - When signed-SBOM certificate revocation checking is requested, Scanner requires a local revocation bundle using the VexLens-compatible
stellaops.vexlens.revocation.v1schema and evaluates it only after DSSE and Fulcio chain validation succeed. Missing, malformed, unsupported, or actively matching revocation entries fail closed; empty bundles only state “no known revocations in this local snapshot” and do not create trust. - Transparency-positive fixtures additionally require verifiable local Rekor material through the Scanner-owned
ILocalRekorTransparencyVerifier, backed by Attestor Core’s offline receipt verifier. - Signed SBOM positive-path verification (Sprint 20260502_008): a deterministic positive fixture pack lives under
src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/Fixtures/SignedSbomArchive/positive/and consists of a CycloneDX 1.5 SBOM (sbom.cdx.json), the in-toto Statement payload signed inside a DSSE envelope (dsse-envelope.json), an offline manifest + metadata pair (manifest.json,metadata.json), and a three-level Fulcio trust chain (root/intermediate/leaf certificate PEMs plus committed PKCS#8 RSA keys underkeys/). The committed envelope’s DSSE payload is the in-toto Statement bytes (not the raw SBOM bytes); the Statement’ssubject[0].digest.sha256binds to SHA-256 ofsbom.cdx.json.SignedSbomArchivePositivePathTestsexercises end-to-end verification throughSignedSbomArchiveBuilder(DSSE payload bind, leaf signature verify, Fulcio chain verify), an in-memory mirror of theISignedSbomMaterialStorehandoff contract, two-run determinism, schema conformance against the WebService-bundledmanifest.v1.schema.json/metadata.v1.schema.json, and DSSE-payload-binding against the committed leaf certificate. Regenerate withsrc/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/Fixtures/SignedSbomArchive/positive/regenerate.sh(setsSTELLA_REGEN_SIGNED_SBOM_FIXTURES=1and runs the opt-inPositiveFixtures_RegenerateOnDemandtest); per-fixture sha256 across two regen runs is byte-identical because RSA keys are committed, certificates use frozen validity windows and explicit fixed serials (root/intermediate/leaf), DSSE PAE + RSA-PKCS#1 v1.5 signing is deterministic, and JSON outputs are written through canonical sorted-key UTF-8 with a trailing newline. - The active owner adapter does not yet make Scanner-owned parts transparency-positive. The exchange contract carries
RekorTileIdonly; it does not carry or verify the receipt, signed checkpoint, local Rekor public key, RFC 6962 Merkle path, or DSSE-envelope binding. Current OfflineKit carrier custody and Scanner activation are therefore not transparency proof. UUIDs, log indexes, unsigned checkpoints, placeholder certificates, JWS headers, Fulcio issuer strings andRekorTileIdalone are diagnostic metadata only and must not produce transparency-verified success. - Scanner enforces local Rekor tile/root consistency for signed-SBOM archives when
CheckRekorTileConsistency=true, whenrekor-tile-snapshot.jsonbytes are supplied, or when a tile snapshot URI/root is configured. The snapshot must satisfy Attestor Core’sstellaops.rekor.tile-snapshot.v1contract: full contiguous tile levels, derived root equal to the signed checkpoint root, leaf atlogIndexbound to the DSSE payload digest, and snapshot-derived inclusion path equal to the receipt path. - Scanner resolves signed-SBOM tile snapshots through the Scanner-owned
IRekorTileSnapshotResolverbefore verification. Resolution precedence is supplied bytes, explicitRekorTileSnapshotUri, offline Rekor log metadata (stellaops.rekor.tile-resolver.v1) deriving a local path underRekorTileSnapshotRootDirectory, thenRekorTileSnapshotRootDirectory/rekor-tile-snapshot.json. Relative URIs are constrained under the configured root; absolute file URIs are root-constrained when a root is supplied; explicit HTTP(S) snapshot retrieval is bounded to 1 MiB and 10 seconds by default; metadata-derived HTTP(S) locations fail closed. All paths feed the same fail-closed local verifier. - Scanner enforces bounded sparse Rekor proof consistency for signed-SBOM archives when
CheckRekorSparseConsistency=trueor whenrekor-sparse-proof.jsonbytes are supplied. Sparse proof material must satisfy Attestor Core’sstellaops.rekor.sparse-tile-proof.v1contract: checkpoint origin/tree/root match, receipt log index match, payload leaf binding, expected audit-path coordinates, receipt hash byte-for-byte match, and recomputed root equal to the signed checkpoint. - Signed-SBOM producer handoff treats
includeRekorProofas packaging metadata only. If the handoff supplies any Rekor receipt/checkpoint/key/log-index field, any tile/sparse material field, or either tile/sparse consistency flag, Scanner runs the local Rekor verifier before durable storage even whenincludeRekorProof=false. Archives emitted with Rekor proof excluded do not return a Rekor log-index header. - Automatic online Rekor lookup, live Rekor tile URL derivation, sparse proof URI/log-metadata derivation, online Fulcio discovery, OCSP, and CRL fetching are not part of Scanner verification. Remaining gaps for this slice are public producer/store wiring for tile metadata fields and online Rekor consistency/tile fetching.
END
- Durable SBOM exports + DSSE-signed receipts (Sprint 20260502_012, G9-EXP-01…03): every successful SBOM export is persisted to a durable content-addressed blob store (
ISbomExportBlobStore) underSbom:ExportStorage(Filesystem or ObjectStorage; the validator rejectsInMemory/no-provider in Production). Each export is wrapped in a DSSE envelope wrapping a canonicalSbomExportReceiptManifest(digest, format, tenant, signed-at UTC, signer identity); the receipt JSON conforms toSchemas/SbomExportReceipt.v1.schema.json(urn:stellaops:scanner:sbom-export-receipt:v1) and is persisted alongside the blob. The signer fails closed whenSbom:ExportReceipt:Signing:HmacSecretBase64is absent — unsigned exports are a configuration error, not a soft fallback, and the export endpoint answers 400. The secret is symmetric (HMAC, algorithm chosen by the compliance profile throughICryptoHmac), and its only holder is scanner-web: the signer and the lineage consumer that re-verifies receipts run in that one process, so no public material leaves it and no other service or client verifies receipts. Provisioning (SPRINT_20260927_002 PCD-142): the release bundle bindsSBOM__EXPORTRECEIPT__SIGNING__HMACSECRETBASE64to${SCANNER_SBOM_EXPORT_RECEIPT_HMAC_SECRET_B64:?...}, which both installers draw as 32 random bytes in base64 into the installation’s.env(GENERATED_B64_32in.env.example), so a bundle without it refuses to start; the local compose stack draws it throughscripts/init-local-env.*. Losing the secret makes stored receipts unverifiable, so it is backed up with.env. Lineage / replay consumers (ISbomExportLineageConsumer) fetch and re-verify the receipt before returning bytes; missing receipts, mismatched digests, or tampered DSSE signatures throwInvalidDataException. The blob store’sGetAsyncis fail-closed on digest drift (the underlying CAS verifies SHA-256 on read). Exports of the same SBOM share one content-addressed receipt path, and the last completed export’s receipt is the stored one. The Filesystem store writes each receipt to a per-call temporary file and renames it into place. Writers and readers of one receipt are serialized within the process. On Windows, a rename onto a file that another process holds open without delete sharing is refused, so the store retries the rename for about 4.5 seconds and then fails the export; on Linux the rename is atomic and is not retried. Concurrent exports of one SBOM from one scanner process succeed on both platforms (SPRINT_20260927_003 PCD-51).
4.2 Localization runtime contract (Sprint 20260224_002)
- Scanner.WebService initializes localization via
AddStellaOpsLocalization(...),AddTranslationBundle(...),AddRemoteTranslationBundles(),UseStellaOpsLocalization(), andLoadTranslationsAsync(). - Locale resolution order is deterministic:
X-Localeheader ->Accept-Languageheader -> configured default locale (en-US). - Translation source layering is deterministic: embedded shared
commonbundle (library) -> embedded Scanner bundle (Translations/*.scanner.json) -> Platform runtime overrides fetched through the remote provider. - Current localized API responses in this rollout are provided for
en-USandde-DE(for example, slice query validation and not-found responses).
4.3 Patch verification signature-store contract
Scanner.Workerwires binary patch verification throughAddPatchVerification(...).- Production-like worker hosts must not silently bind
InMemoryPatchSignatureStore; the default patch verification registration now resolves a fail-closed signature store unless a durableIPatchSignatureStoreis registered. - Development and Testing harnesses may call
AddPatchVerificationInMemory(...)explicitly. This path is restart-only and must not be used for release evidence. - A PostgreSQL patch-signature store is not implemented in this library yet. Runtime deployments that need patch signature persistence must provide a durable
IPatchSignatureStoreimplementation before enabling patch verification evidence.
Report events
When scanner.events.enabled = true, the WebService serialises the signed report (canonical JSON + DSSE envelope) with NotifyCanonicalJsonSerializer and publishes two entries — scanner.event.report.ready and scanner.event.scan.completed(JobEngineEventKinds, Contracts/JobEngineEventContracts.cs) — to the configured stream (default orchestrator:events, EventsOptions.AnalyticsIngestionStream). The transport is not Redis-specific: MessagingPlatformEventPublisher goes through the StellaOps.Messaging abstractions and works over Valkey, PostgreSQL or in-memory. (Corrected 2026-08-11 against src/ — this paragraph previously named the events scanner.report.ready / scanner.scan.completed, the stream stella.events, and the transport “Redis Stream entries”; all three were stale.) 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.
Not to be confused with the DC-15 feed. SPRINT_20260722_017 SCN-2E added a SECOND, differently-named publisher: scanner-worker appends scanner.scan.completed (stream scanner.scans) to the DC-06 transactional outbox in Scanner’s own database, at the terminal success transition, carrying scan facts. The report-events publisher above is scanner-web, fires on report generation, carries report pointers, and rides the messaging pipe. Different name, trigger, payload and pipe — but two lineages nonetheless, which DC-29 says should converge. See scan-completed-contract.md and SCN-2E’s D-SCN2E-5. 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. The release bundle exposes the same knobs as SCANNER_SCANNER__EVENTS__* environment variables (ENABLED defaults to ${SCANNER_EVENTS_ENABLED:-false} there).
DC-15 tenancy and retention (SCN-7/Q-23, 2026-08-28). scanner.scans is the stable logical wire identity, not the physical persistence key. ScanCompletedContract.TenantStream injectively maps each exact, case-sensitive tenant’s UTF-8 bytes to scanner.scans.tenant.<lowercase-hex>. The catch-up route and producer-owned remote-consumer cursor route both derive that tenant from the authenticated request context; neither accepts a caller-selected tenant. Each tenant therefore has an independent epoch, sequence, retention floor and consumer cursor. The RemotelyConsumed retention rule matches only the exact tenant prefix (plus the exact retired global key for pre-cut cleanup), and consumers report progress only after their own projection/checkpoint transaction commits. Contract and digest-detail wire details are in scan-completed-contract.md.
Console scan history (CCP-018, 2026-10-09). Scanner serves no tenant list of scan attempts. The Console’s contributor scan history therefore reads the digest-detail route once per owned image: the digests come from the Release Orchestrator components of the latest release of each owned service, the call carries the session’s scanner:read, and Scanner takes the tenant from the token. The latest attempt (pending, running, failed, cancelled or succeeded) and the latest completed scan are shown separately, so a newer unfinished or failed attempt does not hide the last completed evidence. Only this route’s own not-found problem is shown as “not scanned”; any other failure stays an error. Earlier attempts and their order are not served, so the Console cannot list a digest’s full retry chain; that needs a Scanner-owned, paged attempt read. The route’s captured contract is the scoped document openapi/scan-digest-detail.v1.json (one operation and the schemas it references, cut from the host’s /openapi/v1.json on 2026-10-11; generated reference scan-digest-detail/api-reference.md). It is not a full Scanner capture: Scanner still publishes no openapi/v1.json.
5) Execution flow (Worker)
5.1 Acquire & verify
- Resolve image (prefer
repo@sha256:…). - (Optional) verify image signature per policy (cosign).
- Pull blobs, compute layer digests; record metadata.
5.2 Layer union FS
- Apply whiteouts; materialize final filesystem; map file → first introducing layer.
- Windows package analyzers (MSI / WinSxS / Chocolatey) are implemented (
StellaOps.Scanner.Analyzers.OS.Windows.*); GAC/SxS depth continues to harden.
5.2.1 Pipeline stage executors (pull-layers, build-filesystem)
Sprint
SPRINT_20260522_002. Before this work the Worker had no executor forpull-layersorbuild-filesystem, so a real-image scan reportedSucceededwhile producing zero output (no rootfs → every analyzer short-circuited). These stages now turn an image reference into bytes on disk. (The formersbom-matchstage that forwarded PURLs to Concelier was retired on 2026-09-16, SPRINT_20260914_001 VRP-8.)
pull-layers(PullLayersStageExecutor, StageName => pull-layers):
- Reads
image.reference(orimage.digest) from lease metadata. - Accepts
repo:tag@sha256:<digest>, including numeric tags such as:1. The shared OCI parser separates a tag after the repository’s last slash from a registry port before it. The digest selects the manifest or OCI index; an index resolves to the configured platform without a tag lookup (PCD-343). - Resolves a single-platform manifest via
IOciImageInspector(multi-arch index →linux/amd64by default, configurable), then downloads each non-empty layer blob and the image config viaIRegistryClient.GetBlobAsyncinto a per-scan content-addressed cache (shared layers reused by digest). - Publishes ordered layer-archive paths to
scanner.layer.archives(ScanMetadataKeys.LayerArchives,Path.PathSeparator-joined), the config path toscanner.image.config.path(ImageConfigPath), and the resolved digest toimage.digest. Projects thescanner.imagesrow viaImageRepository. - Fail-closed: unresolvable manifest, no usable platform, or a missing layer/config blob throws → the scan transitions to Failed (no silent empty success). So does an image scan whose target names a raw
sha256:digest and no repository (ScanTargetNotLocatableException), because every later stage would run on an empty filesystem and report a scan with no components. Jobs that carryscanner.source.repository_urlorreplay.bundle.uristill skip the stage, because they take their input from a source checkout or a replay bundle. - Registry bearer tokens obtained by
IOciImageInspectorandIRegistryClientfor aWWW-Authenticate: Bearerchallenge are partitioned by realm, service, scope and the resolved credential snapshot. Both are singleton clients, while the credential provider resolves on each inspection or blob request; a shared registry host, username or repository is not an immutable credential identity. Password/token changes use a separate cache context. The inspector also distinguishes authorization mode and anonymous-fallback policy. - Token reuse stops at the response’s positive
expires_in(60 s when absent or nonpositive), counted from a nonfutureissued_atwhen supplied, less a safety margin of one tenth of the lifetime. The inspector caps the margin at 30 s; the blob client uses the configured maximumRegistry:Client:TokenExpirationBufferSeconds(30 s by default). Both use the injectedTimeProvider. Each cache holds at most 1024 reusable tokens, removes expired entries when inserting, and evicts the earliest deadline when full. Cache keys and entries have opaque diagnostic text; credential/token material is never included in retry logs. - A refused cached token is evicted and exchanged once more for one final authenticated retry. A refused fresh token fails the pull; HTTP 401/403 cannot restart the blob client’s transport retry loop. The returned blob remains streamed and owns its HTTP response until disposal; failed responses are disposed, including challenge responses held while a token exchange throws or is cancelled. Concurrent inspector exchanges for one credential context share one exchange. Its keyed synchronization state exists only while callers are using it, so credential rotations do not leave permanent semaphore keys.
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. A digest-only image target persists pull failed: target names a digest but no repository; submit the image reference with the digest.
build-filesystem(BuildFilesystemStageExecutor, StageName => build-filesystem):
- Extracts the ordered archives onto a single deterministic per-scan rootfs applying OCI whiteout semantics (
.wh.<name>deletes,.wh..wh..opaqueclears a directory), gzip-sniffed per layer, with a path-traversal guard and an extracted-byte cap. - Publishes
scanner.rootfs.path(RootFilesystemPath),scanner.workspace.path(WorkspacePath), andscanner.rootfs.layers(LayerDirectories) so the analyzer dispatcher resolves a non-null rootfs and the OS/language analyzers run. - The per-scan work root is removed by the host after the job (config-gated by
Scanner:Worker:Pipeline:CleanupRootFilesystem).
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.
Retired sbom-match stage. The Worker no longer forwards emitted PURLs to a learn endpoint. SbomMatchStageExecutor and ConcelierLearnClient were deleted on 2026-09-16 (SPRINT_20260914_001 VRP-8) after the Concelier host was frozen under src/__Obsoleted/Concelier/; Matcher:Enabled and Matcher:LearnUrl were removed with them (see the comment in src/Scanner/StellaOps.Scanner.Worker/Program.cs). Advisory matching now happens in the Worker reachability-analysis stage against the Vulnerabilities hub (§5.5.5). The live hub product-issues client reuses the per-tenant token minting of that retired forward, renamed HubServiceTokenHandler and HubServiceTenantContext (SPRINT_20260927_003 PCD-47): the handler requests one client-credentials token per tenant so Authority embeds the matching stellaops:tenant claim. ScanJobProcessor sets HubServiceTenantContext for the whole job from the lease tenant (metadata keys tenant.id, scanner.tenant, tenant, tenantId, TenantId, attr:tenant, first non-empty wins, lower-cased), in the same scope as the integrations-resolver tenant, so every hub call of a job (including the parallel issue-facts reads) carries that job’s tenant in its token and X-StellaOps-TenantId header. A job whose lease carries no tenant uses Scanner:Worker:Pipeline:Matcher:DefaultTenant (SPRINT_20260928_003 PCD-157).
Withdrawn discover-vex-referrers stage. The stage that posted OpenVEX attestations discovered on scanned images to Excititor’s /excititor/ingest/oci-attestations, its client and Scanner:Worker:ExcititorOciAttestations were withdrawn on 2026-09-27 (SPRINT_20260927_003 PCD-47) to src/__Obsoleted/Scanner/StellaOps.Scanner.Worker/PCD-47/. No live service serves that ingest; the Vulnerabilities hub accepts VEX only from allowlisted first-party issuers.
Withdrawn types with no production caller. Three types that read as live were withdrawn on 2026-09-29 (SPRINT_20260928_003 PCD-150), each with a freeze record under src/__Obsoleted/Scanner/**/PCD-150/: the Worker’s NullEpssProvider (nothing registered it; EPSS is read from the hub through HubEpssProvider); VexGateExcititorAdapter with its IVexStatementDataSource contract in StellaOps.Scanner.Gate (no implementation of the data source existed, and Excititor is retired); and the advisory confidence cap (IConfidenceCapPolicy, AdvisoryFreshnessConfidenceCapPolicy) in StellaOps.Scanner.Advisory. Feed freshness therefore does not change reachability confidence; it is reported only through X-StellaOps-Feed-Status and the health payload (advisory-freshness.md).
Withdrawn vex-gate stage and gate engine. The Worker’s vex-gate stage (VexGateStageExecutor) and the Scanner.Gate engine behind it (VexGateService, the default four-rule VexGatePolicy and its evaluator, IVexObservationQuery and its caching provider, the audit logger, VexGateOptions and AddVexGate) were withdrawn on 2026-09-30 (SPRINT_20260929_003 PCD-180, owner ruling) to src/__Obsoleted/Scanner/**/PCD-180/, with the Gate benchmark project and the Gate test folder that never had a project. No host registered the stage or the engine, no VEX source implemented the query, and nothing read the results. VEX reaches scan findings in reachability-analysis: each candidate issue’s live hub statements go through the shared ConsensusEvaluator (HubIssueApplicability), and not_affected, withdrawn, rejected and range-less fixed outcomes are excluded before a finding is emitted. The decision filter that remained in Scanner.Gate, and with it the whole library, was withdrawn on 2026-10-04 (PCD-222, §5.5.6); no live project references Scanner.Gate.
Withdrawn second native analyzer project. src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Native had the same project and assembly name as the live native analyzer (src/Scanner/StellaOps.Scanner.Analyzers.Native) and was referenced only by its own StellaOps.Scanner.Analyzers.Native.Library.Tests. Both were withdrawn on 2026-09-29 (SPRINT_20260928_003 PCD-176) to src/__Obsoleted/Scanner/__Libraries/ and __Tests/, and removed from the Scanner, root and Findings solutions. What it carried (a second ELF reader, demanglers, an ELF init-section call-graph builder and a runtime TimelineBuilder) was never called by a host; the live native analyzer is the only supported path.
Withdrawn advisory linkset enrichment. The WebService client for Concelier’s retired /v1/lnm/linksets route (scanner:concelier, disabled by default), LinksetResolver, POST /api/v1/policy/linksets and the linksets field of report and runtime-policy responses were withdrawn on 2026-09-27 (SPRINT_20260927_003 PCD-47) to src/__Obsoleted/Scanner/StellaOps.Scanner.WebService/PCD-47/. Every install resolved no linksets, and no console, CLI or documented client read them. Advisory consensus is served by the Vulnerabilities hub (GET /api/vulnerabilities/v1/advisories/{id}).
- The
stellaops-scanner-workerservice client is used for the Worker’s outbound calls, not for CLI automation. The release bundle requestsadvisory:ingest,advisory:readandaoc:verify(Scanner__Worker__Authority__Scopes__*); the Integrations client requestsintegration:read integration:operate(Scanner__Worker__Integrations__Scope); the Signals unknowns client requestssignals:write aoc:verify(PCD-187).
Configuration (Scanner:Worker:Pipeline; environment form Scanner__Worker__Pipeline__<Key>, or with the host prefix SCANNER_)
| Key | Env | Default | Purpose |
|---|---|---|---|
DefaultRegistry | Scanner__Worker__Pipeline__DefaultRegistry | (empty — required for port-less refs) | Registry used when a ref omits one; a single-label host equal to this host is treated as the registry, and a configured port is adopted by a port-less ref naming the same host (so with internal-registry:5000 configured, internal-registry/library/... resolves to :5000). No compiled-in host (ADR-040 D2) and no docker.io fallback: a ref that needs this while it is unset fails the pull-layers stage with a named configuration error. |
AllowInsecure | …__PIPELINE__ALLOWINSECURE | false | Plain-HTTP / non-TLS registry access, and disabled server-certificate validation, for every registry this worker touches. An explicit operator opt-in for a trusted in-cluster registry. Per-host plain HTTP without this global switch: give the RegistryHostAliases entry an http:// target, which both the inspector and the blob client honour. |
Platform | …__PIPELINE__PLATFORM | linux/amd64 | Platform 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__CLEANUPROOTFILESYSTEM | true | Remove the per-scan work root after the job. |
MaxExtractedBytes | …__PIPELINE__MAXEXTRACTEDBYTES | 8 GiB | Decompression-bomb guard. |
Matcher:DefaultTenant | …__PIPELINE__MATCHER__DEFAULTTENANT | default | Tenant used by the per-tenant hub clients when lease metadata carries none (Matcher:TimeoutSeconds, default 60, is the request timeout). |
An image reference without a registry, such as nginx:alpine, resolves against DefaultRegistry; it does not imply Docker Hub. DefaultRegistry ships empty, so such a reference fails closed until an operator sets it — the failure names the setting (Scanner:Worker:Pipeline:DefaultRegistry) rather than silently reaching for Docker Hub, which would leave the air-gapped posture without saying so. The operator either sets it and mirrors the image there, or submits a fully qualified reference to an explicitly configured reachable registry.
A reference that names Docker Hub (docker.io/...) is inspected and pulled through registry-1.docker.io, the host that serves the registry API; https://docker.io/v2/ is not a registry endpoint. A single-segment Docker Hub repository gains the library/ namespace, and a digest-pinned reference keeps only the digest. The CLI’s stella sbom generate, inspect and attach use the same normalisation (RegistryApiReference in StellaOps.Scanner.Storage.Oci). A RegistryHostAliases entry for docker.io is applied first, so a configured mirror still wins. The scanner.images row keeps the submitted docker.io/... identity.
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 .runtime/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:apk/, pkg:deb/, pkg:rpm/).
OS package purls follow the purl spec, and hub lookups derive their key through the hub’s contract. APK packages are pkg:apk/{distro}/{name}@{version}?arch={arch}[&origin={source}], where a supported distro is the image’s os-release ID. Alpine is the currently supported apk feed identity. Declared metadata cannot override the extracted image. The reader tries /etc/os-release, then /usr/lib/os-release, including the extracted symlink-placeholder case. Missing or unsupported identity, including Wolfi and Chainguard, omits the namespace and records distributionNamespaceStatus=unscoped with the observed distribution in vendor metadata; the apk o: field remains a source-package name, not a distribution. An unnamespaced apk product key cannot select Alpine distro statements. A known distro with an unknown release keeps its distro key and has no platform stamp. External named Wolfi/Chainguard purls retain their own product keys and never acquire an Alpine image platform through the shared contract. No synthetic release is claimed. DPKG packages are pkg:deb/{distro}/{name}@{version}?arch={arch}[&origin={source}], where the distro is the record’s dpkg Origin, else the image’s os-release ID when it is debian or ubuntu (Ubuntu’s status records carry no Origin), else debian; RPM packages are pkg:rpm/{vendor}/{name}@{epoch:}{version}-{release}?arch={arch}[&origin={source}], where the vendor is the /etc/os-release ID (rhel becomes redhat, every other distro keeps its own ID) or the os.distribution metadata override. Both read /etc/os-release, then /usr/lib/os-release, and a file without an ID falls through: the worker’s extracted rootfs holds a symlink as a placeholder file containing its target, and /etc/os-release is such a symlink in Debian, Ubuntu and Alpine images. The worker’s HubProductIssuesAdapter asks the Vulnerabilities hub for issues by the key ProductKeyV1 derives from each purl, so APK and DPKG packages are looked up by their source package and RPM packages by vendor, binary name, architecture and epoch. The key rules are documented with the hub (Vulnerabilities architecture, section 5).
Installed component versions (PCD-394). OsComponentMapper emits the decoded version from the OS package’s PURL as the SBOM component version. This includes Debian and Ubuntu revisions and epochs, APK revisions, and RPM epoch/version/release. Literal + characters remain literal; percent escapes are URI-decoded. The analyzer’s separate upstream version, epoch and release remain available on OSPackageRecord. A versionless PURL keeps the observed record version. The PURL and its architecture/source qualifiers are unchanged. This makes the emitted component version and package identity agree when SbomService and Findings consume the unmodified JSON. Findings continues to refuse conflicting identities. The shared scanner-sbom-interop-v1 corpus regenerates Debian bookworm and Ubuntu 24.04 inventory bytes through extracted layers, the dpkg analyzer, the real release stamp and the composer; downstream upload and projection tests consume those exact frozen bytes.
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. package-runtime-plugins.ps1 -Module scanner -Profile scanner-runtime stages every OS analyzer project that carries a manifest.unsigned.json as a full publish closure with a stamped signed manifest under .runtime/plugins/scanner/analyzers/os/<manifest id>/ (the complete OS root; Copy-ScannerOsAnalyzerRoot, SPRINT_20260908_003 SAC-4), in addition to the base apk/dpkg/rpm bundles under .runtime/plugins/scanner/base/. Portage gained its signed source manifest in SAC-2, so all 12 OS plug-ins stage into the root. Every staged bundle also carries its runtimes/linux-x64/native/ libraries flattened beside the assemblies (Copy-BundleNativeLibrariesToRoot, SAC-2): a plug-in loaded with Assembly.LoadFrom resolves P/Invoke libraries from its own directory only, which is why the rpm bundle’s Microsoft.Data.Sqlite provider threw DllNotFoundException: e_sqlite3 on every rpmdb.sqlite until then. 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 (.runtime/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 -Profile scanner-runtime (stages base, the full language overlay, and the complete OS root). Normal packaging requires the configured scanner-plugin-manifest Ed25519 purpose key and independent public trust, verifies every newly signed manifest, and refuses source test-seed restamping or pre-signed copy fallback. See configured signing inputs. The e2e-analyzer-coverage lane points its OS host at this root (docker-compose.e2e-analyzer-coverage.override.yml).
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
- apk:
/lib/apk/db/installed - dpkg:
/var/lib/dpkg/status,/var/lib/dpkg/info/*.list - rpm:
/var/lib/rpm/Packages(via librpm or parser) - Record
name,version(epoch/revision),arch, source package where present, and declared file lists.
Data flow note: Each OS analyzer now writes its canonical output into the shared
ScanAnalysisStoreunderanalysis.os.packages(raw results),analysis.os.fragments(per-analyzer layer fragments), and contributes toanalysis.layers.fragments(the aggregated view consumed by emit/diff pipelines). Helpers inScanAnalysisCompositionBuilderconvert 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,OsComponentMapperresolves 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-packageProvidesmatches are normalized to the installed component’s PURL. CycloneDXdependencies[].dependsOntherefore contains only refs present in the same document. Missing, self-only, or ambiguous matches are never turned into phantom nodes; each component reports deterministicstellaops.os.dependencies.resolvedCount,stellaops.os.dependencies.unresolvedCount, and (when non-zero) an ordinalstellaops.os.dependencies.unresolvedRelationslist. 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 keysvendor.changelogBugRefsandvendor.changelogBugToCvesfor DebianCloses,RHBZ#, and LaunchpadLPbug-to-CVE correlation traces used during backport triage.
B) Language ecosystems (installed state only)
- Java:
META-INF/maven/*/pom.properties, MANIFEST →pkg:maven/... - Node:
node_modules/**/package.json→pkg:npm/... - Bun:
bun.lock(JSONC text) +node_modules/**/package.json+node_modules/.bun/**/package.json(isolated linker) →pkg:npm/...;bun.lockb(binary) emits remediation guidance - Python:
*.dist-info/{METADATA,RECORD}→pkg:pypi/... - Go: Go buildinfo in binaries →
pkg:golang/... - .NET:
*.deps.json+ assembly metadata →pkg:nuget/... - Rust: crates only when explicitly present (embedded metadata or cargo/registry traces); otherwise binaries reported as
bin:{sha256}. An ELF file counts as a Rust binary only when it carries Rust evidence: a complete Rust legacy-mangled symbol (_ZN, length-prefixed path segments, the17h+ 16 hex hash segment,E) or the rustc standard-library source path/rustc/<40 hex>/that stripped binaries keep. C and C++ libraries and glibcgconvmodules are not reported. Heuristic crate names come only from the first segment of such a symbol and must be Rust identifiers, and the analyzer never emits a component whose name, version, key, purl, metadata or evidence contains a control character (PCD-816).
Rule: We only report components proven on disk with authoritative metadata. Lockfiles are evidence only.
C) Native link graph
- ELF: parse
PT_INTERP,DT_NEEDED, RPATH/RUNPATH, GNU symbol versions; map SONAMEs to file paths; link executables → libs. - PE/Mach-O (implemented in
StellaOps.Scanner.Analyzers.Native): PE import table + compiler hints (PeReader/PeImportParser/PeCompilerHint); Mach-O load commands, dylib deps, and code signature (MachOReader/MachOLoadCommandParser/MachOCodeSignature); per-format hardening extractors. - Runtime boundary: Worker native SBOM emission and Build-ID lookup use host-owned contracts in
StellaOps.Scanner.Emit.Native, and since SPRINT_20260908_003 SAC-3 the host-ownedElfSectionHashExtractorthere reads.note.gnu.build-idand hashes the configured sections for every discovered ELF. The implementation parser project remains the source of the richer native analyzer code (PE/Mach-O, hardening, imports), but it is not a WorkerProjectReference; signed mounted native plugin/probe work is still required before treating native parsing as a runtime plugin. - Map libs back to OS packages if possible (via file lists); else emit
bin:{sha256}components. - The exported metadata (
stellaops.os.*properties, license list, source package) feeds policy scoring and export pipelines directly – Policy evaluates quiet rules against package provenance while Exporters forward the enriched fields into downstream JSON payloads. - Reachability lattice: analyzers + runtime probes emit
Evidence/Mitigationrecords (seedocs/modules/graph/reachability/guides/lattice.md). The lattice engine joins static path evidence, runtime hits (EventPipe/JFR), taint flows, environment gates, and mitigations intoReachDecisiondocuments that feed VEX gating and event graph storage. - (Project layout note — was stale.) There is no
StellaOps.Scanner.Symbols.NativeorStellaOps.Scanner.CallGraph.Nativeproject (confirmed againstsrc/Scanner/__Libraries/; see also the §1 note). The DWARF/PDB reading, demangling, and native binary parsing live inStellaOps.Scanner.Analyzers.Native; the function-boundary detector + call-edge builder lives inStellaOps.Scanner.CallGraph. These feedFuncNode/CallEdgeCAS bundles and enrich reachability graphs with{code_id, confidence, evidence}so Signals/Policy/UI can cite function-level justifications. The earlier “Sprint 401 introduces*.Symbols.Native/*.CallGraph.Native” wording was a forward-design name that never landed under those project names.
D) EntryTrace (ENTRYPOINT/CMD → terminal program)
- Read image config; parse shell (POSIX/Bash subset) with AST:
source/.includes;case/if;exec/command;run-parts. - Resolve commands via PATH within the built rootfs; follow language launchers (Java/Node/Python) to identify the terminal program (ELF/JAR/venv script).
- Record file:line and choices for each hop; output chain graph.
- Unresolvable dynamic constructs are recorded as unknown edges with reasons (e.g.,
$FOOunresolved). - Runtime
secret://...references are resolved before EntryTrace analyzers run. Missing or unreadable secrets fail the stage with typedentrytrace_secret_*outcomes; literal secret references are never forwarded as placeholder environment values. - Base-image OS package usage is enriched only from explicit EntryTrace path evidence plus OS package file evidence. The worker marks
busyboxreachable when an explicit PID1/terminal path is a BusyBox applet such as/bin/sh,/bin/ash, or the multiplexer itself and the package evidence maps tobusybox; it marksmuslreachable only when an explicit musl loader/libc path such as/lib/ld-musl-*.so.1is present. Package presence alone does not imply reachability, and the original component evidence fields are preserved.
D.1) Semantic Entrypoint Analysis (Sprint 0411)
Post-resolution, the SemanticEntrypointOrchestrator enriches entry trace results with semantic understanding:
- Application Intent — Infers the purpose (WebServer, CliTool, Worker, Serverless, BatchJob, etc.) from framework detection and command patterns.
- Capability Classes — Detects capabilities (NetworkListen, DatabaseSql, ProcessSpawn, SecretAccess, etc.) via import/dependency analysis and framework signatures.
- Attack Surface — Maps capabilities to potential threat vectors (SqlInjection, Xss, Ssrf, Rce, PathTraversal) with CWE IDs and OWASP Top 10 categories.
- Data Boundaries — Traces I/O edges (HttpRequest, DatabaseQuery, FileInput, EnvironmentVar) with direction and sensitivity classification.
- Confidence Scoring — Each inference carries a score (0.0–1.0), tier (Definitive/High/Medium/Low/Unknown), and reasoning chain.
Language-specific adapters (PythonSemanticAdapter, JavaSemanticAdapter, NodeSemanticAdapter, DotNetSemanticAdapter, GoSemanticAdapter) recognize framework patterns:
- Python: Django, Flask, FastAPI, Celery, Click/Typer, Lambda handlers
- Java: Spring Boot, Quarkus, Micronaut, Kafka Streams
- Node: Express, NestJS, Fastify, CLI bin entries
- .NET: ASP.NET Core, Worker services, Azure Functions
- Go: net/http, Cobra, gRPC
Semantic data flows into:
- RichGraph nodes via
semantic_intent,semantic_capabilities,semantic_threatsattributes - CycloneDX properties via
stellaops:semantic.*namespace - LanguageComponentRecord metadata for reachability scoring
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 retired BinaryIndex catalog and disassembly pipeline described below.
E) Binary vulnerability lookup (retired from Scanner)
The scanner-local binary vulnerability stack (BinaryLookupStageExecutor, BinaryFindingMapper, the Build-ID catalog and fingerprint lookup behind BinaryIndex:Enabled) was removed on 2026-08-17 (SPRINT_20260722_014 BIN-5): it was disabled in every compose file and could not start, because its assertion repository was registered only by the retired BinaryIndex web service. The BinaryIndex and Symbols services are retired (src/__Obsoleted/BinaryIndex/); binary and symbol vulnerability operations belong to the Vulnerabilities hub. What remains in Scanner is: native parsing and Build-ID/section-hash evidence (§5.3 C) and EntryTrace-local binary intelligence (above). ML model-file embedding through StellaOps.BinaryIndex.ML was withdrawn on 2026-09-28 (SPRINT_20260927_003 PCD-50). The stella binary CLI group (src/Cli/StellaOps.Cli/Commands/Binary/) is documented in the CLI dossier, not here.
F) Attestation & SBOM bind (optional)
- For each file hash or binary hash, query local cache of Rekor v2 indices; if an SBOM attestation is found for exact hash, bind it to the component (origin=
attested). - For the image digest, likewise bind SBOM attestations (build-time referrers).
5.4 Component normalization (exact only)
- Create
Componentnodes only with deterministic identities: purl, orbin:{sha256}for unlabeled binaries. - Record origin (OS DB, installed metadata, linker, attestation).
5.5 SBOM assembly & emit
- Per-layer SBOM fragments: components introduced by the layer (+ relationships).
- Image SBOMs: merge fragments; refer back to them via CycloneDX BOM-Link (or SPDX ExternalRef).
- Emit both Inventory & Usage views.
- When the native analyzer reports an ELF
buildId, attach it to component metadata and surface it asstellaops:buildIdin CycloneDX properties (and diff metadata). This keeps SBOM/diff output in lockstep with runtime events and the debug-store manifest. - Serialize CycloneDX 1.7 JSON and CycloneDX 1.7 Protobuf; optionally SPDX 3.0.1 JSON-LD (
application/spdx+json; version=3.0.1) with legacy tag-value output (text/spdx) when enabled (1.6 accepted for ingest compatibility). - Build BOM-Index sidecar: purl table + roaring bitmap; flag
usedByEntrypointcomponents for fast backend joins. Currency (2026-09-30):BomIndexBuilder(StellaOps.Scanner.Emit/Index/) exists, but no scan path calls it orScannerArtifactPackageBuilder, so no scan writesbom-index.bin. The scheduler’s impact index is fed from thescanner.scan.completedevent instead (JobEngine’s Scanner image projection).
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:
- Endpoint scheme hygiene (HTTP/WS/plaintext protocol detection).
- Authentication and trust-boundary enforcement.
- Sensitive data flow exposure and unencrypted transfers.
- Deprecated service versions and rate-limiting metadata gaps.
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 and CLI toggles.
Model binary analysis is withdrawn (2026-09-28, SPRINT_20260927_003 PCD-50, main control ruling B). The stage runs six checks: model inventory, model card completeness, training data provenance, bias and fairness, safety risk, and model provenance. It computes no embedding of a model file, and Scanner:Worker:AiMlSecurity:EnableBinaryAnalysis no longer exists. The former embedding library StellaOps.BinaryIndex.ML and ModelBinaryAnalyzer are frozen under src/__Obsoleted/Scanner/__Libraries/: no configuration bound its model options, so it always used a hash-seeded pseudo-embedding; no model shipped; and its similarity index had no implementation. Re-verify with the ModelBinaryAnalysisWithdrawalTests class in the worker test project.
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.
Scanner owns the stateless rebuild engine, local/remote/air-gap backends, determinism and SBOM stability validators, and symbol extraction under src/Scanner/__Libraries/StellaOps.Scanner.BuildProvenance/Reproducibility/. Source commit 0f3c71e3eab819ecdffb80dbeaa275b14e986fb3 split those from the former BinaryIndex repository-backed ground-truth tooling and wired them through AddBuildProvenance; the Worker graph reaches no BinaryIndex ground-truth project. Re-verify with the BuildProvenance test project plus pwsh ./tools/scripts/build-boundary/generate-build-boundary-report.ps1 -Check.
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:
reachability.report(JSON) for component and vulnerability reachability.reachability.report.sarif(SARIF 2.1.0) for toolchain export.reachability.graph.dot(GraphViz) for dependency visualization.
Configuration lives in src/Scanner/docs/sbom-reachability-filtering.md, including policy schema, metadata keys, and report outputs.
Reachability producer configuration (source, 2026-09-11). Scanner:Worker:Reachability:HubAdvisoryBaseUrl is the single hub service root for product-issues matching and affected-symbol lookup whenever reachability is enabled. Since DAY-15 (SPRINT_20260925_004) the matcher also reads each candidate issue’s live statements from GET /api/vulnerabilities/v1/issues/{vulnerabilityId}/{productKey}/facts and keeps an issue only when the shared consensus rule applies to the installed version (see advisory evidence identity section 5). Vulnerabilities:Consensus:Trust (IssuerTiers, TierPrecedence, DefaultTier) carries the issuer trust, with Policy’s defaults; Scanner:Worker:Reachability:HubIssueFactsMaxParallelism (default 8) bounds the concurrent facts reads per scan. The completion log line reports issueCandidates, excludedByStatus, excludedByVersion, excludedByPlatform and noLiveStatements. Since SPRINT_20260930_008 DRS-1 the worker records the image’s distro release (debian:bookworm, ubuntu:22.04, alpine:3.19, rhel:9) on each deb and apk OS-package component, and on each Red Hat rpm (pkg:rpm/redhat/) of a RHEL or UBI image, as the SBOM property stellaops.os.platform, read from the rootfs /etc/os-release, else /usr/lib/os-release (a placeholder file that names no platform falls through, as above), and the matcher scopes each issue’s statements to that release before consensus. DistroPlatformV1.ComponentPlatform decides which components carry it: OS packages whose purl namespace is the image’s distro. Since CCP-063 (acceptance manifest S5) a release that the statements of the image’s packages do not name, while they name other releases of the same distro, is not covered by any ingested feed (DistroReleaseCoverageV1): the report is unavailable with the reason release-not-covered:<release> instead of a clean zero, and the worker logs the uncovered releases at warning level.
PCD-396 applies the same host stamping to SUSE RPM packages: pkg:rpm/sles/ on a sles image, pkg:rpm/sled/ on a sled image, and pkg:rpm/opensuse-leap/ on an opensuse-leap image. The image’s known release is stamped after the real RPM analyzer and component mapper; another namespace does not borrow it, and an unknown release stays unscoped. The shared SUSE corpus excludes a foreign service pack’s larger bound from an already fixed package.
Signals unknowns producer (PCD-187, 2026-10-02). When Scanner:Worker:Reachability:SignalsBaseUrl is an absolute HTTP(S) root (the reachability overlay sets http://signals.stella-ops.local), publish-signals-callgraph runs after prepare-reachability-inputs and posts its non-empty snapshot to POST /signals/callgraphs (PCD-345). The JSON artifact carries sorted nodes, edges and entrypoints, with scan id, image digest and Scanner graph digest in metadata. The returned Signals callgraphId and source digest are retained in the scan context; the same accepted snapshot is reused within that context. publish-signals-unknowns then posts the extracted graph’s unresolved edges to Signals’ POST /signals/unknowns. An edge is an unknown when its target is not a node of the graph (missing-target-node), when the extractor explained it as Unknown (unresolved-callee), or when it is a reflection or dynamic call (CallKind Reflection/Dynamic, or a Reflection/ DynamicLoad explanation: dynamic-call-target). Entries are distinct by caller, callee and reason, ordered ordinally, and capped at SignalsUnknownsMaxEntries (default 1000). The subject is the scan id with the image digest, and the call-graph id is Signals’ stored id when publication succeeds (the Scanner graph digest remains the fallback on publication failure). Signals replaces a subject’s rows on every post, so a retried scan does not duplicate them. The worker posts under its own stellaops-scanner-worker identity with a per-tenant token for signals:write aoc:verify (Authority issues signals:* only beside aoc:verify). Delivery is best-effort: a refused or failed post is logged and the scan continues. A scan without a source tree has no call graph and posts nothing. The deriver, the request shape and the publisher are Worker-internal (Processing/Reachability/SignalsUnknowns/). The shared fixture src/__Tests/corpus/signals-unknowns-v1 pins the wire shape from both sides: a Worker test asserts the stage posts exactly its request, and a Signals host test posts that request to the Production host on PostgreSQL.
Signals accepts all eight Scanner extraction language keys. Recompute uses the stored Signals graph id, a scan subject, entrypoints and targets; it requires signals:admin. The producer retains its existing signals:write aoc:verify scope. Publishing a graph does not itself request recompute. A Signals host test executes the real Worker graph and unknowns publishers over HTTP, then recomputes that scan and verifies non-zero unknowns pressure in PostgreSQL. Its artifact store is isolated filesystem storage. The 2026-10-05 estate probe on clean source 89992ccc46 used the installed SeaweedFS S3 store: scan d157f4738fafcedd10a68e4aa834a700e1a4ec04 Succeeded, stored Signals graph 1fdaafd1f9f64c1c9531f0965063ff55, and recompute persisted two unknowns with pressure 0.6666666666666666. The same graph with a subject lacking unknowns produced zero pressure. Writer-only recompute returned 403; anonymous graph read returned 401.
JavaScript unknowns (PCD-344, 2026-10-05). A bare call that the in-process extractor cannot resolve retains an edge without a target node; the Worker derives missing-target-node from the real snapshot. This preserves uncertainty without inventing a callee, entrypoint or sink. Comments and literal contents are excluded from structural/call matching, and imported member calls do not produce a second unresolved bare-call edge. Template-interpolated calls and unresolved member dispatch remain explicit coverage gaps.
Reachability drift producer (SPRINT_20261017_006 PCD-874, 2026-10-19). The reachability-drift stage runs after publish-signals-unknowns. It stores the call graph that prepare-reachability-inputs extracted in scanner.call_graph_snapshots, keyed by tenant, scan, language and graph digest. The language is the drift router’s canonical name (an extracted javascript graph is stored as node, with its digest recomputed); a language outside the router’s set is not stored. The tenant is the scan’s own scan_runtime_state owner, never lease metadata; a scan without a recorded tenant stores nothing. When the scan submission declared a base scan in the metadata key reachability.drift.base_scan_id, the stage loads that scan’s stored graph of the same tenant and language, extracts the code changes, detects the drift and stores code_changes and then the drift result (reachability_drift_results, drifted_sinks). A base scan of another tenant, or one without a stored graph in that language, yields no drift and a warning log. A storage failure fails the stage. The scan submit route (scanner.scans.submit) accepts the declared base only when the scan coordinator resolves it for the submitting tenant (the PCD-864 visibility gate); an unknown or foreign scan is refused with 400 before a scan exists, and the canonical key with the scan’s own identifier reaches the worker. The drift GET without baseScanId and the drifted-sinks route then read what the stage stored; the Console screen /security/reachability/drift reads both. The extractor yields one language per scan today (the first candidate with a non-empty graph), so one snapshot is stored per scan.
Reachability without dependency evidence (DAY-15). The SBOM analyzer marks a component Unreachable (no-path) only when the SBOM’s dependency relations connect it, in any direction and scope, to an entry point without a runtime path reaching it. A component the dependency relations do not connect to any entry point, which is every package of an image SBOM that lists packages without edges, is Unknown (no-dependency-evidence). The reachability policy’s MarkUnknownAs then applies, so such matches are not filtered as unreachable. The v2 emission/shadow switches, legacy advisory matcher and AdvisoryBaseUrl setting are removed. ScannerReachabilityRuntimeConfigurationValidator rejects an enabled lane without its hub URL. Symbol lookup uses GET /api/vulnerabilities/v1/advisories/{vulnerabilityId}/symbols; see advisory affected symbols for provenance and bounds. The source tests exercise the new owner path; the sprint separately records deployed acceptance. The historical forcing-function record at docs-archive/qa/feature-checks/runs-2026-live-snapshot/reachability-live-forcing-function/RUN_REPORT.md is point-in-time evidence for the old v1 path, not live proof for the current hub path.
Hub identity (SCN-ID-1…6, DONE 2026-09-03). The worked solution is advisory-evidence-identity.md. Evidence uses the hub’s global, case-sensitive (vulnerabilityId, productKey) issue identity, keeps linksetContentHash as a separate observation revision, and writes a strictly versioned stellaops.reachability-report@v2 through one canonical codec. SCN-ID-6 retired the Scanner-owned advisory matcher and every fresh v1 producer (the legacy branch of SbomReachabilityStageExecutor and its shadow observation), so the hub path is the only matcher. Historical v1 bytes and hashes remain readable through a frozen archival decoder. A failed product lookup produces explicit unavailable rather than a clean zero, and Release Orchestrator lets only available pass the release gate. Record: docs-archive/implplan/SPRINT_20260722_017_Scanner_database_separation.md (SCN-ID).
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. Per-node moduleHash, addr, file, and line values are carried only when the graph supplied them. Unavailable module hashes and addresses are omitted from the PoE node; Scanner never substitutes sha256:placeholder or 0x0, and the Attestor emitter rejects those values if another caller supplies them.
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:
PoE:SigningKeyIdset to a deployment key id, never the defaultscanner-signing-2025.PoE:Signer:Urlpointing at the deployed Signer service.PoE:Signer:ScannerImageDigestset to the real Scanner worker image digest (sha256:<64 hex>).PoE:Signer:ProofOfEntitlementFormat=jwt,PoE:Signer:ProofOfEntitlement, and a freshPoE:Signer:DpopProofbound to the Signer request and access token.- Either
PoE:Signer:BearerTokenorScanner:Worker:Authority:Enabled=trueso the outbound client can obtain asigner:signtoken.
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.
PoE CAS addresses emitted from 2026-07-31 onward are sha256:<64 hex> and are verified against the PoE bytes on read. Earlier builds wrote the same SHA-256 digest under a misleading blake3:<64 hex> address and blake3_<64 hex> directory. The compatibility resolver accepts that prefix only as this historical mislabelled SHA-256 form, logs the compatibility read, rehashes the bytes with SHA-256, and returns the canonical sha256: address. It never treats those directories as proof that BLAKE3 was computed. New writes never use the legacy directory form.
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 Withdrawn VEX filter, VEX-candidate and gate-result routes (PCD-222)
On 2026-10-04 (SPRINT_20260930_002 PCD-222) Scanner withdrew, under CODE_OF_CONDUCT 15.4, every VEX route that read as live but had no caller and could not return data. Sources are frozen under src/__Obsoleted/Scanner/**/PCD-222/; nothing replaces them.
| Withdrawn route | Why |
|---|---|
POST /api/v1/scans/vex-reachability/filter | Evaluated a caller-supplied (vendor status x reachability tier) matrix (VexReachabilityDecisionFilter, the last code in StellaOps.Scanner.Gate). No caller in src, devops or tools. |
GET /api/v1/scans/{scanId}/gate-results, /gate-summary, /gate-blocked | Always answered 501 vex_gate_results_unsupported: nothing has produced gate results since the Worker’s vex-gate stage was withdrawn (PCD-180). |
GET /api/v1/scans/gate-policy | Served the configured scanner:vex gate policy, which nothing applies since PCD-180; only the gate routes read it. |
GET /api/v1/smart-diff/{scanId}/vex-candidates, POST /api/v1/smart-diff/{scanId}/vex-candidates/review, GET /api/v1/smart-diff/images/{digest}/candidates, GET /api/v1/smart-diff/candidates/{candidateId}, POST /api/v1/smart-diff/candidates/{candidateId}/review | Read PostgresVexCandidateStore, whose only writer VexCandidateEmitter no host constructed. |
The scanner:vex options, the tenant policy resolver and its scanner-vex-policy-changed subscriber went with them. VEX reaches scan findings in the Worker’s reachability-analysis stage (hub live statements through the shared ConsensusEvaluator), and Policy’s vuln-gate projection applies the same consensus to promotions.
PCD-302 (2026-10-05) also withdrew the unpopulated scan-completed event member vexGate and its VexGateSummaryPayload DTO. The DTO’s source is frozen under src/__Obsoleted/Scanner/StellaOps.Scanner.WebService/PCD-302/; it is excluded from the shipped assembly. No producer populated that field, so its removal does not change the event bytes previously emitted with null fields omitted. The real-host withdrawal tests pin route/store absence and both event-contract absences. Verified against f62ebcabfd plus the PCD-302 withdrawal; re-verify with VexRouteWithdrawalTests and ReportEventDispatcherTests.
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:
- Core clustering service:
StellaOps.Scanner.Triage/Services/ExploitPathGroupingServicegroups findings using common call-chain prefix similarity with configurable thresholds. - Inbox enhancements:
GET /api/v1/triage/inboxsupportssimilarityThreshold,sortBy, anddescendingfor deterministic cluster filtering/sorting. - Cluster statistics:
GET /api/v1/triage/inbox/clusters/statsreturns per-cluster severity counts, reachability distribution, and priority scores. - Batch triage actions:
POST /api/v1/triage/inbox/clusters/{pathId}/actionsreturns501with problem codescanner.triage.mutation_unavailablefor an owned cluster. It applies no mutation and returns no updated finding IDs or action record. An absent or foreign cluster still returns404. - Offline/determinism posture: clustering uses stable ordering by IDs/path IDs and deterministic path-ID hashing without network calls.
The same unavailable response applies to POST /api/v1/triage/findings/{findingId}/status and POST /api/v1/triage/findings/{findingId}/vex after existing scope, tenant and input checks. Missing or foreign findings retain their 404 response. These operations do not create decisions, VEX statements, snapshots or success identifiers, and they preserve stored records. Read APIs continue to expose existing data; the ordinary bulk-query/summary implementation and decision loading remain separate incomplete work. The response is application/problem+json and does not include internal exception details. See the API change note.
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:
- Tenant context is resolved by
ScannerRequestContextResolverthrough the canonical claim resolver. The canonical tenant-selection header is honored only for a global-admin principal; ordinary raw headers do not select another tenant. - Triage/finding service contracts require explicit
tenantIdand all retrieval/update paths filter by tenant before resolving finding/scan identity. - Triage schema includes tenant discriminators (
triage_scan.tenant_id,triage_finding.tenant_id), and active-case uniqueness includestenant_idto prevent cross-tenant collisions. - Cross-tenant finding lookups resolve as deterministic not-found responses rather than revealing record existence.
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:
- host environment is not
Production; and scanner:triageDemo:enabled=trueis explicitly configured (SCANNER__TRIAGEDEMO__ENABLED=truefor 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 requires ScannerPolicies.ScansRead (any of scanner.scans.read or scanner:read) at class level, so an unauthenticated request answers 401 before the fixture switch is consulted. The host sets no fallback authorization policy, so every MVC controller must declare [Authorize] with a named Scanner policy or a registered [AllowAnonymous]; ScannerControllerAuthorizationConformanceTests enforces that over every controller in the assembly and keeps the anonymous register (empty today) shrink-only. Client-side scanner:read preflight remains a usability guard, not the security boundary. 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:
- Request tenant resolution uses
ScannerRequestContextResolver, a facade over the shared claims-onlyStellaOpsTenantResolver(a global admin may select a tenant withX-StellaOps-TenantId); a request without a tenant fails with400(tenant_missing). - Unknown list/detail/evidence/history/stats/bands handlers call a tenant-scoped query service that filters by
tenant_id. The column isUUID; the query service compares it in that type withScannerTenantScope.Resolve(tenant).TenantId, the identity Scanner’s repositories store (SPRINT_20261017_006 PCD-847 item a; the earlier text comparison with the slug never matched). No production path writesscanner.unknownsyet. - Cross-tenant detail lookups resolve as deterministic not-found responses (
404).
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:
- Source run APIs (
/api/v1/sources/{sourceId}/runs,/api/v1/sources/{sourceId}/runs/{runId}) pass tenant intoISbomSourceRunRepositoryforGetByIdAsync,ListForSourceAsync, andGetStatsAsync; SQL predicates includetenant_id = @tenantId. - Secret exception APIs (
/api/v1/secrets/config/exceptions/{tenantId}/{exceptionId}) use tenant-scoped repository methods for get/update/delete onsecret_exception_pattern, removing ID-only tenant-agnostic operations. - All eight secret settings/exception routes also compare the route UUID with the canonical selected tenant before invoking the service. Ordinary claims and global-admin canonical-header selection retain their existing resolver semantics; unresolved or empty selection returns
400, and a mismatched route returns403. The rules catalog remains scope-controlled without a tenant target. Scanner has no configured slug directory resolver, so no slug-to-UUID mapping is inferred. Owner repository updates and match recording bind optional fields with explicit database types, preserving SQL NULL and the existing identity, expiry-selection and explicit-delete custody rules. Administrative CRUD and these checks do not qualify runtime consumption of persisted secret configuration. - Generic webhook ingress by
sourceIdremains compatibility-tolerant when tenant context is absent, but enforces tenant ownership when context is present.
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.
- Format:
stellaops.reachability-catalog-pack.v1(StellaOps.Scanner.Contracts/CatalogPacks/):registrySinks(fully-qualified symbol patterns forSinkRegistry.MatchSink/MatchSinkLexical),matcherSinks((module, symbol, category) tuples consumed by the per-language matchers with their language-specific semantics),entrypoints(declarative patterns, e.g. Pythondecorator-prefix). CWE ids and severity weights ride the pack entries. Array order is significant (first match wins) and preserved. - Default packs ship as embedded resources in
StellaOps.Scanner.Contracts(stellaops.sinks.default.catalog.json,stellaops.entrypoints.default.catalog.json) — extracted content-equal from the pre-D7 in-code tables and pinned by a byte-parity test against a frozen copy of those tables (DefaultCatalogPackParityTests/LegacyCatalogTables). - Operator packs (air-gap extension flow): drop
<name>.catalog.json+<name>.catalog.dsse.jsoninto the drop folder (host.runtime/plugins/scanner/catalog-packs/, mounted at/app/plugins/scanner/catalog-packs; configured viaScanner.Worker:Reachability:CatalogPackDirectory) and enroll the signing public key as<keyId>.pemunderCatalogTrustedKeysDirectory. Verification is offline (ECDSA P-256 over the DSSE PAE; the payload must byte-equal the pack file) and fail-closed: unsigned, tampered, or untrusted-key packs abort Worker startup. Operators extend catalogs (internal frameworks’ sinks/entrypoints) without image rebuilds. - Merge semantics (deterministic): defaults first, operator packs in ordinal file-name order; an entry whose identity ((language, pattern) / (language, module, symbol), occurrence-indexed for legitimate duplicates) collides with an earlier pack’s entry replaces it in place (overrides keep first-match-wins positions); new entries append.
- Witness pinning (the attestation hole closed): every
PathWitnesscarriesevidence.catalog_packs— name, version, sha256 content digest, and source (builtin/operator) of every loaded pack, snapshotted from the same process-wideReachabilityCatalogRuntimesnapshot the match engines read. A catalog edit therefore changes the witness id and is visible in the attested evidence; it can never silently change verdicts. - Remaining scope (tracked in sprint 20260610_002): entrypoint classifier tables beyond Python’s decorator set (Go/JS/Java/Rust/Ruby/PHP structural heuristics) still live in code, and the unrouted Bun/Deno matchers keep their in-code tables until the LANG-JS ride-along rework.
5.6 DSSE attestation (via Signer/Attestor)
- WebService constructs predicate with
image_digest,stellaops_version,license_id,policy_digest?(when emitting final reports), timestamps. - Calls Signer (requires OpTok + PoE); Signer verifies entitlement + scanner image integrity and returns DSSE bundle.
- Worker PoE uses the same Signer trust boundary for exposure proofs: the Worker emits deterministic PoE JSON locally, then asks Signer for the DSSE envelope. Signer remains the production signing authority; Scanner test fixtures may use deterministic in-process signers only inside tests.
- Attestor logs to Rekor v2; returns
{uuid,index,proof}→ stored inartifacts.rekor. - Verdict OCI idempotency:
push-verdictcomputessha256idempotency from DSSE envelope bytes, writesorg.stellaops.idempotency.key, uses stableverdict-<hash>manifest tags, retries transient push failures (429/5xx/timeouts), and treats conflict/already-submitted responses as success. - Hybrid reachability attestations: graph-level DSSE (mandatory) plus optional edge-bundle DSSEs for runtime/init/contested edges. See
docs/modules/graph/reachability/guides/hybrid-attestation.mdfor verification runbooks and Rekor guidance. - Operator enablement runbooks (toggles, env-var map, rollout guidance) live in
operations/dsse-rekor-operator-guide.mdper SCANNER-ENG-0015.
6) Three-way diff (image → layer → component)
6.1 Keys & classification
- Component key: purl when present; else
bin:{sha256}. - Diff classes:
added,removed,version_changed(upgraded|downgraded),metadata_changed(e.g., origin from attestation vs observed). - Layer attribution: for each change, resolve the introducing/removing layer.
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)
Release Orchestrator event delivery
Source verified against commit
163f35a2ceon 2026-10-03 for this subsection. Re-verify from the repository root withrg -n 'ValidatePage|ValidatePayload|ReportCheckpointAsync|EnsureFencedAsync' src/Scanner/StellaOps.Scanner.Worker/Processing/Scm/ReleaseOrchestratorScannerEventBridgeService.cs. The earlier document-wide snapshot does not cover this contract; installation acceptance is separate from this source verification.
Scanner Worker consumes ComponentVersionScmInfoAvailable and BuildContextReachabilityAvailable through Release Orchestrator’s authenticated, tenant-partitioned P6 retained-stream API. The closed StellaOps.ReleaseOrchestrator.ScannerEvents.Contracts project defines the wire boundary; Scanner reads no Release Orchestrator SQL. SCM events queue deterministic source-backed scans, while build-context events enter Scanner’s existing OCI referrer coordinator.
Both consumers are disabled by default. An enabled path requires a configured owner URL, explicit tenant allowlist and tenant-bound release:read credentials. Scanner owns durable inbox checkpoints and renewable processing leases in its own database. It handles each event before advancing a fenced checkpoint, then reports persisted progress to the owner for retention. Malformed events, tenant disagreement, unavailable owners and lost leases cannot advance progress past the failed event; epoch and retention gaps require explicit recovery. See configuration and recovery.
BuildKit descriptor tool
src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin is a console tool described by the restart-time plug-in manifest stellaops.sbom-indexer.manifest.json (id stellaops.sbom-indexer, kind buildx-generator, image name stellaops/sbom-indexer). Its verbs (Program.cs) are:
handshake- validates the manifest and the local CAS root (BuildKit generator handshake).manifest- prints the plug-in manifest.descriptor(aliasannotate) - takes an already produced SBOM (--image <digest>,--sbom <path>, optional media type/format/kind, build and source provenance options such as--repository,--commit-sha,--build-id,--pipeline-id,--scan-job-id, and optional surface inputs--surface-layer-fragments/--surface-entrytrace-graph) and emits a descriptor for an OCI referrer; with--attestor <url>(orSTELLAOPS_ATTESTOR_URL) it also calls that Attestor endpoint for the provenance placeholders (AttestorClient).version,help.
The tool does not run analyzers itself; SBOM production for CI is stella sbom generate / stella sbom attach (§0, “Build-time attach”). The project is packaged through devops/release/components.json (buildxPlugin) and its manifests ship under devops/etc/plugins/scanner/buildx/.
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 therediskind; see §1.1 and the Path A binding in §1.1, which useskind=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"
# PostgreSQL: never configured here. Set STELLAOPS_POSTGRES_SCANNER_CONNECTION
# (or Postgres:Scanner:ConnectionString); see §3.2.
s3:
endpoint: "http://s3.stella-ops.local:8333" # SeaweedFS S3-compatible endpoint
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
Parallelism: per-analyzer concurrency; bounded directory walkers; file CAS dedupe by sha256.
Distributed locks per layer digest to prevent duplicate work across Workers.
Registry throttles: per-host concurrency budgets; exponential backoff on 429/5xx.
Targets:
- Build-time: P95 ≤ 3–5 s on warmed bases (CI generator).
- Post-build delta: P95 ≤ 10 s for 200 MB images with cache hit.
- Emit: CycloneDX Protobuf ≤ 150 ms for 5k components; JSON ≤ 500 ms.
- Diff: ≤ 200 ms for 5k vs 5k components.
10) Security posture
AuthN: Authority-issued short OpToks (DPoP/mTLS).
AuthZ: per-endpoint scope policies declared in
Security/ScannerPolicies.csand bound inProgram.cs. The canonical Authority scopes (StellaOps.Auth.Abstractions/StellaOpsScopes.cs) arescanner:read,scanner:scan,scanner:export,scanner:write, plus the machine-only tenant projection scopescanner:projection:read. Scan submission (scanner.apipolicy) is an any-of policy acceptingscanner:scan/scanner:writeas well as the granularscanner.scans.enqueue/scanner.scans.writescopes; ordinary read paths acceptscanner:readorscanner.scans.read. The DC-15 catch-up and remote-consumer cursor routes are the exception: they require onlyscanner:projection:readplus an authenticated tenant, while the digest-detail route remains an ordinary tenantscanner:readoperation. Scanner operations contracts use named any-of policies:scanner.operations.readacceptsscanner:readorscanner.admin, andscanner.operations.writeacceptsscanner:writeorscanner.admin; the policy names are not new scope claims. Granular scopes used by the service (Security/ScannerAuthorityScopes.cs) includescanner.scans.read|write|approve,scanner.reports.read,scanner.runtime.ingest,scanner.callgraph.ingest,scanner.signed-sbom-material.write,scanner.sources.read|write|admin,scanner.secrets.settings.read|write,scanner.secrets.exceptions.read|write,secrets:bundle:read|write, andscanner.admin. Generic offline-kit HTTP policy belongs to OfflineKit after Q-25; Scanner-specific activation uses a Scanner-owned seam and scopes. There is noscanner.catalog.readscope.ScannerAuthorityScopesis an alias layer over the canonical catalog, not a second catalog (SPRINT_20260712_002 / AUTH-2). Every scope enforced by a single-scope policy —scanner.runtime.ingest,scanner.callgraph.ingest,scanner.admin,scanner.secrets.{settings,exceptions}.{read,write},secrets:bundle:{read,write},scanner.signed-sbom-material.write— is now aStellaOpsScopesconstant thatScannerAuthorityScopesdelegates to, and is seeded intoauthority.permissions(S038). This is load-bearing: Authority registers onlyStellaOpsScopes.Allwith OpenIddict, so an uncatalogued claim can never be minted into a token and its endpoint would be permanently unreachable. The dot-form claim values were promoted verbatim (never renamed to colon-form) so already-issued tokens and existingclients.allowed_scopeskeep working.The any-of aliases (
scanner.scans.*,scanner.reports.read,scanner.sources.*) are deliberately left off-catalog: each is registered alongside the canonicalscanner:read/scanner:writefallback, so those endpoints are reachable via the canonical scope and cataloguing the alias would only duplicate the canonical pair.mTLS to Signer/Attestor; only Signer can sign.
No network fetches during analysis (except registry pulls and optional Rekor index reads).
Sandboxing: non-root containers; read-only FS; seccomp profiles; disable execution of scanned content.
Release integrity: release bundles pin every image by sha256 digest beside its semver; deploy by digest.
Production startup throws if
Scanner:Authority:AllowAnonymousFallbackistrueor ifScanner:Authority:Enabledisfalse. The check (StellaOps.Scanner.WebService.Hosting.ScannerRuntimeConfigurationValidator) runs after options binding and short-circuits only forDevelopment,Testing,TestingLocalHarness, and the explicitScanner:LocalHarness:Enabled=trueopt-in. Operators rolling production compose profiles must declareSCANNER_SCANNER__AUTHORITY__ENABLED=trueandSCANNER_SCANNER__AUTHORITY__ALLOWANONYMOUSFALLBACK=falseexplicitly so the credential-derived default at registry-credential resolution time can never silently re-enable fallback.Offline-kit ownership boundary (Q-25): Scanner does not own the generic carrier, import/status/manifest/validate API, quarantine/CAS, bundle lifecycle or import ledger. Those are OfflineKit data and behavior. Current canonical status proves carrier custody only; it is not whole-bundle activation evidence. Scanner now owns activation for the exact artifact-BOM part delivered through
/api/internal/v1/offlinekit/artifact-boms/activate; the bounded export route is/api/internal/v1/offlinekit/artifact-boms. The part is size/digest/schema-verified before dispatch, but local Rekor transparency verification remains unimplemented. The former internalOfflineKitAuditRecord/IOfflineKitAuditEmitterexisted only for Scanner’s duplicate import surface and retires with that source cut; do not restore it or an Authority persistence source edge. Durable Scanner persistence and the OfflineKit per-part activation receipt remain on their respective sides of the owner contract. A durable Scanner-owned activation audit beyond the artifact-BOM rows themselves remains future work and must cross external boundaries only through owner contracts.
11) Observability & audit
Metrics (verified names; the full list with sources is in
observability.md):- Queue:
scanner_queue_enqueued_total,scanner_queue_deduplicated_total,scanner_queue_ack_total,scanner_queue_retry_total,scanner_queue_deadletter_total - Cache:
scanner.layer_cache_hits_total/_misses_total/_evictions_total,scanner.file_cas_hits_total/_misses_total/_evictions_total - Worker:
scanner_worker_job_duration_ms,scanner_worker_stage_duration_ms,scanner_worker_jobs_completed_total,scanner_worker_jobs_failed_total,scanner_worker_queue_latency_ms scanner_analyzer_golang_heuristic_total{indicator,version_hint}- increments whenever the Go analyzer falls back to heuristics (build-id or runtime markers). Grafana panel:sum by (indicator) (rate(scanner_analyzer_golang_heuristic_total[5m])).entrytrace_resolutions_total{outcome},entrytrace_unresolved_total{reason},scanner_proofspine_validation_failures_total{reasonCode,tenantId}
- Queue:
Tracing: spans for acquire→union→analyzers→compose→emit→sign→log.
Audit logs: DSSE requests log
license_id,image_digest,artifactSha256,policy_digest?, Rekor UUID on success.
12) Testing matrix
Reachability test lanes
The reachability project’s default run keeps collection parallelism enabled. The catalog-provenance mutation tests use a nonparallel collection because changing the process-wide catalog also changes witness identities, even when matcher behavior for other languages stays the same. The golden replay tests keep their byte-equality assertions and remain in the parallel run.
Wall-clock and memory budgets in the three reachability benchmark classes run only with STELLAOPS_PERFORMANCE_TESTS=1; every budget retains its original assertions. The functional repeated-trimming determinism test stays in the default run. PerformanceLaneGateTests prevents budget checks from losing their gate and the catalog mutation tests from losing their collection isolation. The impact-set budget invokes the production ImpactSetCalculator on a 50K-node snapshot and checks the affected methods and entries; it does not time a copied impact algorithm.
From the repository root:
& ./tools/scripts/test-targeted-xunit.ps1 -Project src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/StellaOps.Scanner.Reachability.Tests.csproj -All -MaxThreads default
$env:STELLAOPS_PERFORMANCE_TESTS = '1'
& ./tools/scripts/test-targeted-xunit.ps1 -Project src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/StellaOps.Scanner.Reachability.Tests.csproj -Trait 'Category=Performance' -MaxThreads 1 -AdditionalRunnerArgument @('-parallel', 'none')
Remove-Item Env:STELLAOPS_PERFORMANCE_TESTS
Run the opt-in lane on a measured, otherwise idle host. A budget failure measures that host and workload; do not raise a threshold to make a contended unit run pass.
Analyzer contracts: see
language-analyzers-contract.mdfor cross-analyzer identity safety, evidence locators, and container layout rules. Per-analyzer docs:analyzers-java.md,dotnet-analyzer.md,analyzers-python.md,analyzers-node.md,analyzers-bun.md,analyzers-go.md. Implementation:docs-archive/implplan/SPRINT_0408_0001_0001_scanner_language_detection_gaps_program.md.Determinism: given same image + analyzers → byte-identical CDX Protobuf; JSON normalized.
OS packages: ground-truth images per distro; compare to package DB.
Lang ecosystems: sample images per ecosystem (Java/Node/Python/Go/.NET/Rust) with installed metadata; negative tests w/ lockfile-only.
Native & EntryTrace: ELF graph correctness; shell AST cases (includes, run-parts, exec, case/if).
Diff: layer attribution against synthetic two-image sequences.
Performance: cold vs warm cache; large
node_modulesandsite-packages.
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.
- Security: ensure no code execution from image; fuzz parser inputs; path traversal resistance on layer extract.
13) Failure modes & degradations
- Image absent from the configured registry: fail the pull stage with a bounded actionable reason; bare image references resolve against
Scanner:Worker:Pipeline:DefaultRegistry, so mirror the image there or submit a fully qualified reference to another configured reachable registry. - Bare image reference with no
DefaultRegistryconfigured: fail the pull stage closed with a bounded reason that names the setting. There is no built-in registry host and no docker.io fallback, so this is a configuration gap, not a transient error — setScanner:Worker:Pipeline:DefaultRegistryor submit a fully qualified reference. - Missing OS DB (files exist, DB removed): record files; do not fabricate package components; emit
bin:{sha256}where unavoidable; flag in evidence. - Unreadable metadata (corrupt dist-info): record file evidence; skip component creation; annotate.
- Dynamic shell constructs: mark unresolved edges with reasons (env var unknown) and continue; Usage view may be partial.
- Missing Worker stage executor: omit the stage from scan progress; do not record a successful no-op duration or evidence-looking placeholder stage result. NB:
pull-layersandbuild-filesystemare always registered and fail-closed (SprintSPRINT_20260522_002) — a pull/extract failure throws so the scan is marked Failed, never a silent empty success. - EntryTrace secret resolution failure: fail the EntryTrace stage before analyzer invocation; do not substitute unresolved
secret://values. - PoE prerequisite/signing unavailable: fail PoE generation with a typed reason until real reachability CAS, build/image/policy/config metadata, configured Signer HTTP signing, and non-empty DSSE output are present. Production-like Worker startup rejects enabled PoE with missing/default signer configuration before jobs are accepted.
- Verdict push prerequisite unavailable: when verdict push is enabled, require real registry, decision, image digest, SBOM digest, feeds digest, policy digest, and DSSE envelope material; do not push OCI verdicts with
unknowndigest placeholders. - Registry rate limits: honor backoff; queue job retries with jitter.
- Signer refusal (license/plan/version): scan completes; artifact produced; no attestation; WebService marks result as unverified.
- Missing signed evidence verifier: signed SBOM archive and witness verification endpoints fail closed instead of producing placeholder signatures, placeholder certificates, or
validwitness status from envelope shape alone. - Advisory or EPSS data behind on the Vulnerabilities hub: scans still complete (fail-open); every WebService response carries
X-StellaOps-Feed-Status: fresh|stale|missingand/healthzlists per-feed status.HubFeedFreshnessRefresherrecords the hub’s data timestamps (newest sourcelastCompletedAt, EPSS model date) for theadvisoryandepssfeeds; a failed or empty poll records nothing. See advisory-freshness.md.
14) Optional plug-ins (off by default)
- Patch-presence detector (signature-based backport checks). Reads curated function-level signatures from advisories; inspects binaries for patched code snippets to lower false-positives for backported fixes. Runs as a sidecar analyzer that annotates components; never overrides core identities.
- Runtime evidence: the Kubernetes-oriented Zastava observer is retired. Runtime observations reach Scanner through
POST /api/v1/runtime/eventsandPOST /api/v1/runtime/reconcile(policyscanner.runtime.ingest); host-side collection belongs to the RuntimeInstrumentation agents (src/RuntimeInstrumentation/).
15) DevOps & operations
- Release bundle wiring (
devops/release/bundle/docker-compose.yml, digest-pinned imagesstellaops/scanner-webandstellaops/scanner-worker):scanner-cache-init(one-shot, worker image) creates/var/lib/stellaops/cache/scanner/{layers,cas}and/var/lib/stellaops/surfaceand hands them to uid 10001; both hosts depend on its successful completion, pluspostgres,rustfs(SeaweedFS) andvalkeyhealth.scanner-web:ASPNETCORE_URLS=http://+:8444, aliasesscanner.stella-ops.localon the internal network and thefrontdoornetwork; Router registration (Router__Enabled, consumer groupscanner); artifact storeSCANNER_SCANNER__ARTIFACTSTORE__*(S3 driver, bucketscanner-artifacts); Authority resource-server settingsSCANNER_SCANNER__AUTHORITY__*(enabled, anonymous fallback off); queueSCANNER_scanner__queue__*(kind=redis, streamscanner:jobs); signed plug-in admissionScannerAnalyzerHost__*with allRequire*switchestrue; scan attestationSCANNER_SCANNER__SCANATTESTATION__*; SBOM export storageSBOM__EXPORTSTORAGE__*; owned connectionSTELLAOPS_POSTGRES_SCANNER_CONNECTION(required, no fallback).scanner-worker: aliasscanner-worker.stella-ops.local; the same queue and owned connection;ScannerStorage__*object store; OS and language analyzer hosts (ScannerOsAnalyzerHost__*,ScannerAnalyzerHost__*) over/app/plugins/scanner/base;Scanner__Worker__Authority__*client credentials;Scanner__Worker__Integrations__*;Scanner__Worker__Pipeline__*(registry host aliases,AllowInsecure);Scanner__Worker__Reachability__*(hub advisory URL, Release Orchestrator URL, policy path); volumesscanner-cache-data,scanner-plugin-scratch,scanner-poe-cas-data,scanner-surface-cache.
- Local compose:
devops/compose/docker-compose.stella-services.ymldefines the same three services for the development estate; overlays such asdocker-compose.plugins.scanner-os.yml,docker-compose.scanner-poe-dev.ymland the e2e analyzer-coverage override are optional. - HA: WebService horizontal scale; Workers scale by queue depth. The CAS capacity guard is a pre-write check, not a global quota (§3.3).
- Retention: see §3.2 (RFC-1 classes, daily cleanup and the artifact-BOM partition window) and
operations/retention-classification.md. Object Lock applies to compliance artifacts whenArtifactStore:EnableObjectLockis set (off in the bundle). - Upgrades: migrations are forward-only and run at
scanner-webstartup; deploy both images from the same release by digest. - Backups: PostgreSQL
stellaops_scannerand thescanner-artifacts/surface-cachebuckets.
16) CLI & UI touch points
- CLI (
src/Cli/StellaOps.Cli/Commands/):stella scan run,stella scan upload,stella scan entrytrace,stella scan sarif,stella scan layers,stella scan layer-sbom,stella scan recipe(the last three read the per-layer SBOM routes),stella registry scan(submit a registry image scan),stella sbom generate,stella sbom attachandstella sbom inspect(build-time SBOM and referrer attach/read). - UI: Scan detail shows Inventory/Usage toggles, Diff by Layer, Attestation badge (verified/unverified), Rekor link, and EntryTrace chain with file:line breadcrumbs.
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.
- Buildx generator GA (certified external registries), cross-registry trust policies.
- Durable reachability backend wiring so
compute-reachabilityand the reachability query/explain endpoints move off their501 Not Implementedplaceholders. - Patch-presence plug-in GA (opt-in) + a durable PostgreSQL
IPatchSignatureStore(today only a fail-closed / in-memory store exists); cross-image corpus clustering (evidence-only; not identity). - Advanced EntryTrace (POSIX shell features breadth, busybox detection).
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
- WebService ships a RecordModeService that assembles replay manifests (schema v1) with policy/feed/tool pins and reachability references, then writes deterministic input/output bundles to the configured object store (RustFS default, S3/Minio fallback) under
replay/<head>/<digest>.tar.zst. - Bundles contain canonical manifest JSON plus inputs (policy/feed/tool/analyzer digests) and outputs (SBOM, findings, optional VEX/logs); CAS URIs follow
cas://replay/...and are attached to scan snapshots asReplayArtifacts. - Reachability graphs/traces are folded into the manifest via
ReachabilityReplayWriter; manifests and bundles hash with stable ordering for replay verification (docs/modules/replay/guides/DETERMINISTIC_REPLAY.md). - Worker sealed-mode intake reads
replay.bundle.uri+replay.bundle.sha256(plus determinism feed/policy pins) from job metadata, persists bundle refs in analysis and surface manifest, and validates hashes before use. - Deterministic execution switches (
docs/modules/scanner/deterministic-execution.md) must be enabled when generating replay bundles to keep hashes stable. - Canonical scan submission/runtime state is durable in PostgreSQL rather than process-local memory: the live host persists scan status, replay bundle attachment, entropy snapshots, and the latest report-attestation DTO in
scanner.scan_runtime_state, andPersistedScanCoordinatoris now the authoritative runtime coordinator inStellaOps.Scanner.WebService.
EntryTrace emits structured diagnostics and metrics so operators can quickly understand why resolution succeeded or degraded:
| Reason | Description | Typical Mitigation |
|---|---|---|
CommandNotFound | A 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. |
MissingFile | source/./run-parts targets are missing. | Bundle the script or guard the include. |
DynamicEnvironmentReference | Path depends on $VARS that are unknown at scan time. | Provide defaults via scan metadata or accept partial usage. |
RecursionLimitReached | Nested includes exceeded the analyzer depth limit (default 64). | Flatten indirection or increase the limit in options. |
RunPartsEmpty | run-parts directory contained no executable entries. | Remove empty directories or ignore if intentional. |
JarNotFound / ModuleNotFound | Java/Python targets missing, preventing interpreter tracing. | Ship the jar/module with the image or adjust the launcher. |
Diagnostics drive two metrics published by EntryTraceMetrics:
entrytrace_resolutions_total{outcome}— resolution attempts segmented by outcome (resolved,partiallyresolved,unresolved).entrytrace_unresolved_total{reason}— diagnostic counts keyed by reason.
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
The format below is what BomIndexBuilder produces; as of 2026-09-30 no scan path writes it (see §5.5).
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)
| Type | Purpose |
|---|---|
StackTraceExploitPathView | Root view: ordered frames, CVE IDs, severity label, collapsed state |
StackTraceFrame | Single frame: symbol, role, source location, snippet, gate label |
SourceSnippet | Syntax-highlighted source extract at a frame location |
FrameRole | Entrypoint / Intermediate / Sink / GatedIntermediate |
StackTraceViewRequest | Build request with source mappings and gate labels |
Frame Role Assignment
| Position | Has Gate | Role |
|---|---|---|
| First | — | Entrypoint |
| Last | — | Sink |
| Middle | No | Intermediate |
| Middle | Yes | GatedIntermediate |
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)
BuildView(StackTraceViewRequest)— transforms a singleExploitPathinto aStackTraceExploitPathViewBuildViews(IReadOnlyList<StackTraceViewRequest>)— batch transformation, ordered by priority score descending then path ID for determinism
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)
SPRINT_20260226_224_Scanner_oci_referrers_runtime_stack_and_replay_datais the scanner execution contract for:- OCI 1.1 referrer capability probing and fallback handling.
- DSSE verification during slice retrieval/publish paths.
- CAS-backed replay data resolution and deterministic command generation.
- persisted reachability stack and deterministic runtime collector fixture flows.
Advisory Gap Status (2026-03-05 Update)
Gaps translated in the 2026-03-04 advisory batch are now implemented in Scanner:
SCN-001closed:DeltaCompareServicenow computes deterministic snapshot deltas, persists by deterministiccomparisonId, and supports retrieval.SCN-002closed: actionables are generated from actual delta findings/policy changes with deterministic ordering by priority then actionable ID.SCN-003closed:ChangeTraceBuilderno longer uses placeholder traces; subject digests are content-addressed and binary comparison uses real file bytes/hashes.SCN-004closed: runtime ingestion now indexes scan-to-trace relationships and returns deterministically ordered trace lists.SCN-005closed: exploitable/likely/possible stack verdicts emitReachabilityResult.Affected(PathWitness)when witness context exists, with explicit unknown fallback when entrypoint evidence is absent.SCN-006closed: score replay contracts expose/api/v1/scans/{scanId}/score/*. The/api/v1/score/{scanId}/*compatibility aliases no longer exist (Endpoints/ScoreReplayEndpoints.cs).SCN-007historical delivery exposed factor vectors and canonical input metadata. PCD-403 withdraws its digest-derived numeric scorer: the current signed manifest binds addresses without frozen measurements, so default replay returns a stable422before producing new proof/history data.- Score bundle verification is fail-closed across service restarts. In-process replay metadata supplies the expected/effective canonical input hash; after restart, callers must provide both an expected canonical hash and the canonical input payload. Missing either side returns
CanonicalInputHashValid=falseinstead of accepting an unbound proof bundle.
Delivered in:
docs-archive/implplan/2026-03-05-completed-sprints/SPRINT_20260304_302_Scanner_trace_delta_and_actionables_completion.mddocs-archive/implplan/2026-03-05-completed-sprints/SPRINT_20260304_303_Scanner_score_replay_contract_and_formula_alignment.md
Scan manifest input ownership (PCD-303, 2026-10-05)
The typed manifest uses vulnerabilitiesSnapshotHash, vexSnapshotHash and required schemaVersion: 2; its DSSE predicate is scanmanifest.stella/v2. Startup migration 012_scan_manifest_owned_inputs.sql renames feed_hash and rules_hash to vulnerabilities_snapshot_hash and vex_snapshot_hash, preserving hashes and JSON content. Historical signed v1 envelopes and bundles retain their original bytes and can be verified through generic DSSE verification. They cannot be relabelled or re-signed for typed v2 replay; DSSE retrieval and score replay reject unsupported versions with 422. A new scan is required for a v2 manifest. See the manifest API contract.
PCD-403 removes digest-derived factor values. The address-only scoring boundary refuses unavailable attributable frozen CVSS/EPSS/reachability/provenance measurements with scanner.score_replay.measurements_unavailable. Manifest signature verification precedes this refusal; no scoring node, bundle or history entry is written. Generic knobs and current owner data are not substituted for frozen inputs. Existing stored artifacts retain their cryptographic verification path; valid signatures do not prove metric semantics. Re-verify with DeterministicScoringServiceTests, ScoreReplayUnavailableInputsTests and the stored bundle contract cases in ScoreReplayEndpointsTests using the targeted xUnit runner. Verified against bf5d3c5b5f024db40f200593bb052746def175f1 on 2026-10-06; PCD-403 acceptance (pruned evidence docs/implplan/_evidence/20261006-scanner-jobengine-carry/PCD-403.md at d887e8d5e2) records the refusal and separate stored-proof controls.
