Scanner Architecture
Stella Ops Scanner subsystem (baseline 2025Q4). Aligned with Epic 6 – Vulnerability Explorer and Epic 10 – Export Center.
Scope. Implementation-ready architecture for the Scanner subsystem: WebService, Workers, analyzers, SBOM assembly (inventory & usage), per-layer caching, three-way diffs, artifact catalog (RustFS default + PostgreSQL, S3-compatible fallback), attestation hand-off, and scale/security posture. This document is the contract between the scanning plane and everything else (Policy, Excititor, Concelier, UI, CLI). Related:
docs/modules/scanner/operations/ai-code-guard.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 — the backend (Policy + Excititor + Concelier) decides presentation and verdicts. The
POST /reportsendpoint surfaces a verdict (pass/warn/blocked/escalated) but only by delegating toPolicyPreviewService; Scanner itself computes no policy decision. - 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. This bounded lane does not implement the older cross-module Attestor proof-spine aggregate.
Scan-result attestation anchoring and fail-visible contract (SPRINT_20260721_003 / SPRINT_20260721_004). When scanner:ScanAttestation is enabled, ScanAttestationService signs the report DSSE and submits it to the internal Attestor transparency log at POST /api/v1/rekor/entries. This is an internal tokenless bypass submission (authorized by the Attestor’s BypassNetworks, no bearer token), so Scanner forwards the scan tenant in X-StellaOps-TenantId; the Attestor hard-requires that tenant.
Submission failures are classified at the HTTP boundary, never by parsing an exception message:
| Attestor 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 devops/plugins/scanner/<profile>/<plugin-id>/ to /app/plugins/scanner/<profile>/<plugin-id>/. The base compose points Scanner Web at SCANNER_SCANNER__PLUGINS__BASEDIRECTORY=/app/plugins, SCANNER_SCANNER__PLUGINS__DIRECTORY=/app/plugins/scanner/base, and SCANNER_SCANNER__PLUGINS__TRUSTSTOREDIRECTORY=/app/trust-roots/plugins/scanner so the WebService verifies the same mounted base bundle directory it reports through /internal/plugins/{status,probe}. Scanner Worker points both ScannerAnalyzerHost__PluginDirectory and ScannerOsAnalyzerHost__PluginDirectory at /app/plugins/scanner/base, mounts config at /app/etc/plugins/scanner, trust roots at /app/trust-roots/plugins/scanner, and writes probe output under /var/lib/stellaops/plugin-scratch/scanner. Both hosts run the same signed manifest, trust-root, and assembly-hash admission before inspecting the entry point contract. A verified bundle owned by the sibling host is skipped for that host to activate; a verified type implementing neither known Scanner analyzer contract is rejected fail-closed. This permits the base root to carry five language and three OS bundles without activation errors or weakening trust. Analyzer policy, signatures, and rule catalogs remain declarative data unless an analyzer bundle is admitted through the signed plugin path. The recommended compose overlay keeps Scanner on the base analyzer set. The optional scanner-full-analyzers overlay mounts the complete signed language bundle at the canonical runtime root /app/plugins/scanner/analyzers and points ScannerAnalyzerHost__PluginDirectory there; it also mounts /app/plugins/scanner/base for the signed base OS analyzers used by ScannerOsAnalyzerHost. The optional Scanner OS overlay changes only ScannerOsAnalyzerHost__PluginDirectory to /app/plugins/scanner/analyzers/os; it does not redirect the language host. The harness overlay points at the signed Hello-Lang sample analyzer (stellaops.scanner.plugin.hello) so dummy probe evidence uses the same IScannerLanguageAnalyzerPlugin contract as third-party analyzers without adding production analyzer payloads to the image.
Secrets bundle runtime surface (updated 2026-06-06). Scanner WebService and Worker no longer reference the executable StellaOps.Scanner.Analyzers.Secrets implementation project. Bundle manifest, verification, store, DSSE envelope, change-notification contracts, and Worker-facing secrets evidence DTOs live in StellaOps.Scanner.Surface.Secrets. The Worker secrets stage consumes ISecretsAnalyzerRuntime; the default implementation loads the legacy secrets analyzer assembly from the mounted bundle path configured under Scanner:Worker:Secrets:{PluginDirectory,PluginAssemblyPath,PluginAssemblyName,PluginTypeName}. This removes image/source coupling, but it is still a transitional runtime adapter: final Scanner acceptance still requires a signed manifest-bound secrets plugin entry point, read-only compose mount, trust-root admission, and probe proof.
SBOM dependency reachability inference uses dependency graphs to reduce false positives and apply reachability-aware severity adjustments. See src/Scanner/docs/sbom-reachability-filtering.md for policy configuration and reporting expectations.
Source/build-stage call-path evidence is prepared before dependency reachability when scanner.source.path, scanner.build_stage.rootfs.path, or scanner.attached_sbom.path metadata is present. Static runtime images that no longer contain manifests or source trees must provide a build-stage filesystem root or attached CI SBOM to produce useful SBOM and call-path evidence. The detailed input contract, emitted metadata keys, and coverage limits live in callpaths-build-stage-sbom.md. When Release Orchestrator emits ComponentVersionScmInfoAvailable after a digest-pinned component version gains both sourceRepoUrl and sourceCommitSha, Scanner.Worker consumes that domain event through the ScmInfoAvailableTrigger bridge. The bridge creates a deterministic source-backed scan id from tenant, component, version, digest, repository URL, and commit SHA; writes a Pending row to scanner.scan_runtime_state; and enqueues the scan with scanner.source.repository_url, scanner.source.commit, and scanner.source.reference attributes. Duplicate events for the same tuple are idempotent: pending scans are re-enqueued, while completed scans are not. When the event includes imageReference, the bridge stores it as the scan target reference, serializes it into the queue payload, and emits the image.reference queue attribute. Release Orchestrator supplies this as a digest-pinned imageName@sha256:<digest> reference on build-time co-production events so the Scanner worker can resolve the OCI repository for subject pulls and referrer publication; legacy events without the field still fall back to digest-only metadata. Source checkout treats archive downloads as a hard prerequisite for these scans. For HTTP(S) SCM repository URLs, Scanner.Worker first tries deterministic archive candidates anonymously so public repositories and local archive fixtures do not depend on integrations-web. If anonymous archive download fails, the worker asks integrations-web for active IntegrationType.Scm rows, matches the source repository host, resolves the credential through the trusted /resolve-credentials path, unseals the mandatory response envelope in memory, and applies the provider-appropriate archive credential header (PRIVATE-TOKEN for GitLab, token auth for Gitea, bearer/basic otherwise). When no SCM integration matches, the original anonymous archive failure is reported. When an active SCM integration matches but its credential cannot be resolved or the shared response-domain KEK is absent/mismatched, the worker fails closed instead of falling back to anonymous or accepting plaintext transport. For build-time co-production reruns where Release Orchestrator supplies an already-staged file:// source context (for example a mounted /var/lib/stella-agent/... build context), checkout bypasses integrations-web and materializes the local directory or zip archive into the same per-scan workspace metadata used by the archive path. If a private SCM host follows redirects to an HTML login page or otherwise returns a non-zip response, the scan fails with the candidate URL, final URL, content type, and a short sanitized prefix instead of surfacing a generic ZIP extraction error. Operators must configure an authenticated source integration or direct archive endpoint before private-repository reachability can resolve. Operators can disable or tune the bridge under Scanner:Worker:ScmInfoAvailableTrigger (Enabled, PollInterval, BatchSize, StartAfterEventId).
Registry auto-scan trigger (Sprint 20260607_001, AUTO-002/003/006). The sibling RegistryImageScanTrigger + RegistryImageDiscoveredBridgeService close the operator-reported gap “registry added but its images are never scanned ⇒ SBOM-readiness absent ⇒ deploy gate blocks”. When a registry integration becomes Active, integrations-web enumerates its repositories/tags, resolves each tag to its image digest, and appends a row to integrations.registry_image_discoveries (an append-only outbox; see the Integrations dossier). The Scanner.Worker bridge polls that outbox cross-schema — exactly as the SCM bridge polls release_orchestrator.domain_events — and routes each (tenant, registryHost, repo, tag, digest) into RegistryImageScanTrigger. The trigger:
- 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, StartAfterEventId, FailedRetryMaxAttempts, FailedRetryMinInterval).
Build-time co-production (Sprint 20260604.017, ADR-024) reuses this same ScmInfoAvailableTrigger for images that Stella both builds and pushes itself via the EPF build.docker plugin. There is no separate scanner path for Stella-built images: the orchestrator publishes the identical ComponentVersionScmInfoAvailable event (keyed to the freshly-pushed digest) and this bridge submits the identical source-derived scan. The only difference versus a post-hoc, externally-built image is the reachability referrer’s source provenance annotation:
When a submitted image reference uses an operator-visible registry address that is not reachable from the Scanner container, Scanner:Worker:Pipeline:RegistryHostAliases maps that exact authority (host and port) to the container-network authority. The mapping is applied before the primary pull-layers manifest inspection and blob downloads, as well as the SCM/referrer paths, so scan readiness and metadata attach operate on the same digest. Alias matching is authority-exact: an alias for 127.1.1.5:80 does not implicitly rewrite another port on the same host.
| Image 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. This is local Scanner evidence. Durable ReachGraph storage 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 pushes the same bytes as an OCI artifact/referrer through ISbomOciPublisher. The artifact is attached to the immutable image digest and uses a stable sbom-cdx-<sbom sha256> tag plus org.stellaops.* annotations for SBOM digest, image digest, scan job id, and registry-source id. Missing fragments, unsupported formats, missing image digest, or registry push failures are fail-soft: the scan continues and the skip/failure is logged for operators.
1) Solution & project layout
The two runnable hosts sit directly under src/Scanner/ (StellaOps.Scanner.WebService/, StellaOps.Scanner.Worker/); the bulk of the code is shared libraries under src/Scanner/__Libraries/, tests under src/Scanner/__Tests/, and benchmarks under src/Scanner/__Benchmarks/. The Cartographer service also lives in-domain at src/Scanner/StellaOps.Scanner.Cartographer/ (see §1.0). Representative layout (not exhaustive — the actual __Libraries set is ~80 projects):
src/Scanner/
├─ StellaOps.Scanner.WebService/ # REST control plane, exports, manifests, proofs
├─ StellaOps.Scanner.Worker/ # queue consumer; executes the analyzer pipeline
├─ StellaOps.Scanner.Cartographer/ # Cartographer service (Scanner-owned)
└─ __Libraries/
├─ StellaOps.Scanner.Core / .Contracts / .Models … # DTOs, evidence, graph nodes, contracts
├─ StellaOps.Scanner.Storage (+ .Storage.Oci) # PostgreSQL repos (auto-migrated); RustFS/object client; ILM/GC
├─ StellaOps.Scanner.Queue # transport abstraction (Redis/Valkey Streams, NATS)
├─ StellaOps.Scanner.Cache # layer cache; file CAS; bloom/bitmap indexes
├─ StellaOps.Scanner.EntryTrace # ENTRYPOINT/CMD → terminal program resolver (shell AST) + Semantic engine
├─ StellaOps.Scanner.Analyzers.OS{,.Apk,.Dpkg,.Rpm,.Pacman,.Portage,.Homebrew,
│ .MacOsBundle,.Pkgutil,.Windows.Msi,.Windows.WinSxS,.Windows.Chocolatey}
├─ StellaOps.Scanner.Analyzers.Lang{,.Java,.Node,.Bun,.Deno,.Python,.Go,.DotNet,.Rust,
│ .Ruby,.Php,.Ccpp,.Dart,.Elixir,.Swift} (+ .LangAdapter)
├─ StellaOps.Scanner.Analyzers.Native # ELF + PE + Mach-O readers/parsers + hardening extractors
├─ StellaOps.Scanner.Analyzers.Secrets # Secret leak detection
├─ StellaOps.Scanner.CallGraph # function/call-edge builder + CAS emitter
├─ StellaOps.Scanner.Emit (+ Composition CDX 1.7 JSON/Protobuf/XML) / StellaOps.Scanner.Sarif
├─ StellaOps.Scanner.Diff / .SmartDiff / .Delta / .ChangeTrace # diff + delta-compare surfaces
├─ StellaOps.Scanner.Reachability / .ReachabilityDrift / .Gate / .Triage
├─ StellaOps.Scanner.ProofSpine / .ProofIntegration / .Manifest / .Evidence / .Explainability
├─ StellaOps.Scanner.ServiceSecurity / .CryptoAnalysis / .AiMlSecurity / .BuildProvenance / .PatchVerification
├─ StellaOps.Scanner.Surface{,.Env,.FS,.Secrets,.Validation} / .VulnSurfaces / .Runtime / .Registry / .Sources
└─ StellaOps.Scanner.Plugin.{Contracts,Loader,SigningTool}
# Tools/ (sibling root): StellaOps.Scanner.Sbomer.BuildXPlugin (BuildKit generator)
# (no StellaOps.Scanner.Sbomer.DockerImage project exists under src/)
Note (was stale): there is no
StellaOps.Scanner.Symbols.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):scanner-workerno longer compiles the Native analyzer implementation project directly. The Worker-owned native discovery/execution path now consumes Build-ID index and section-hash abstractions fromStellaOps.Scanner.Emit.Native; the current host section-hash extractor is a no-op until a signed native-stage bundle and probe contract are completed.FuncProof composition status (verified 2026-07-19): FuncProof currently exists as uncomposed component libraries: model/builder, DSSE and transparency services, OCI publisher, PostgreSQL repository, and CycloneDX linker. Scanner Worker and WebService do not register or coordinate these components into a production scan pipeline, so native-image FuncProof generation/publishing is not a current runtime capability. Computed graph-purpose digests carry the active
ICryptoHashprefix; standalone fallback computation uses SHA-256 and carriessha256:rather than a misleadingblake3:label.
1.0 Cartographer Ownership (Sprint 201)
- Cartographer is owned by Scanner and implemented at
src/Scanner/StellaOps.Scanner.Cartographer/. - The service remains a separate deployable endpoint (
cartographer.stella-ops.local, slot 21, ports 10210/10211) while living inside the Scanner domain. - Legacy
src/Cartographer/paths are retired; operational and build references now resolve through Scanner-owned solution/project paths.
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/reach-graph/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 or Rekor submission. A bare payload hash is never emitted as a DSSE signature. Explicitly signed CAS/Rekor and air-gapped paths remain covered, butAddReachabilityWitnessAttestation()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/reach-graph/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 leases fresh messages (LeaseAsync), and when none are available re-claims leases abandoned by a crashed worker (ClaimExpiredLeasesAsync, idle threshold =scanner:queue:redis:claimIdleThresholdor the lease duration). On acquire it marks the scanRunning; onCompleteAsyncit acknowledges the stream lease and marksSucceeded; onPoisonAsync(retry budget exhausted) it dead-letters and marksFailed;AbandonAsyncreleases for retry without a terminal write. Read-model write-back is best-effort (PostgresScanRuntimeStateWriter, scoped repository resolved per transition) and never aborts the lease lifecycle; a storage-less worker usesNullScanRuntimeStateWriter. - Operator failure contract. On retry exhaustion,
failure_reasoncontains 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. 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. - 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. - WebService registry image search and digest lookup must use a configured registry integration. The endpoint must return
501 Not Implementedrather than static catalog data or deterministic fake digests when no integration exists.
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).
- RustFS (default, offline-first) for SBOM artifacts; S3-compatible interface with Object Lock semantics emulated via retention headers; ILM for TTL.
- PostgreSQL for catalog, job state, diffs, ILM rules.
- Queue (Valkey Streams/NATS/RabbitMQ).
- Authority (on-prem OIDC) for OpToks (DPoP/mTLS).
- Signer + Attestor (+ Fulcio/KMS + Rekor v2) for DSSE + transparency.
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)
Persistence lives in StellaOps.Scanner.Storage and is auto-migrated on startup via AddStartupMigrations<ScannerStorageOptions>(...) (per the repo-wide DB auto-migration rule). SQL migrations are embedded resources under Postgres/Migrations/ (compacted into 001_v1_scanner_storage_baseline.sql plus incremental files 002–009; the pre-1.0 chain is archived under Migrations/_archived and excluded from embedding); tables use snake_case columns, not Mongo-style _id documents.
Core catalog tables (public schema):
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)— scan identity;entry_trace,ruby_packages,bun_packagesare keyed byscan_idand hold decoded inventories for CLI/Policy reuse.
The schema is much larger than the catalog. Notable durable tables (public + scanner schema): scan_runtime_state (status, entropy, replay, and latest report-attestation read-model), scan_manifest + proof_bundle + score_replay_history (manifest/proof/score-replay), scanner.scan_signed_sbom_materials (signed-SBOM material store), scanner.sbom_uploads + scanner.sbom_sources/sbom_source_runs (BYOS/SCM), scanner.scan_evidence_projections, proof_spines/proof_segments/proof_spine_history, call_graph_snapshots/reachability_results/reachability_stacks/reachability_stack_frames/reachability_drift_results, unknowns, scan_findings, the EPSS layer (epss_raw/epss_scores(partitioned)/epss_current/epss_changes/epss_signal), smart-diff (risk_state_snapshots/material_risk_changes/vex_candidates), binary_identity/binary_package_map/binary_vuln_assertion/binary_patch_verifications, scanner.witnesses/witness_verifications, secret_detection_settings/secret_exception_pattern, and classification_history (with the fn_drift_stats materialized view). Many scanner.* tables enable row-level security and carry a tenant_id. The Triage library ships its own schema under StellaOps.Scanner.Triage/Migrations/.
Reachability evidence projection
The Worker persists reachability_findings in scanner.scan_evidence_projections as the Findings-facing evidence boundary. Every projected row carries the exact vulnerabilityId and componentPurl, the public verdict state, the declared analysisMethod, and a deterministic pathSymbols witness when the analyzer proved a path. Static call-graph results preserve the analyzer’s method and actual call path; entrypoint-only evidence declares component-entrypoint-usage; dependency-only evidence declares sbom-only. Scanner never upgrades missing method/path data into inferred proof.
The Scanner evidence HTTP contract returns the same optional analysisMethod without renaming or interpreting it. Consumers must join by both CVE and component PURL: a stronger record for the same CVE on another package is not evidence for the requested finding.
Smart-Diff runtime boundary (verified 2026-07-17): the WebService registers
PostgresMaterialRiskChangeRepository,PostgresVexCandidateStore,ManifestBackedScanMetadataRepository, and the Smart-Diff read/review/SARIF routes. It does not register or callMaterialRiskChangeDetectororVexCandidateEmitter, and neither class has a production caller in Scanner. Consequently the tables and APIs are durable retrieval/review surfaces, not a live automatic scan-completion pipeline. Wiring automatic VEX generation first requires a production lifecycle owner that supplies real previous/current finding snapshots, vulnerable-API inventories, call graphs, and per-finding reachability gates; missing inputs must not be synthesized as unknown evidence.
3.3 Object store layout (RustFS)
layers/<sha256>/sbom.cdx.json.zst
layers/<sha256>/sbom.spdx.json.zst
images/<imgDigest>/inventory.cdx.pb # CycloneDX Protobuf
images/<imgDigest>/usage.cdx.pb
indexes/<imgDigest>/bom-index.bin # purls + roaring bitmaps
diffs/<old>_<new>/diff.json.zst
attest/<artifactSha256>.dsse.json # DSSE bundle (cert chain + Rekor proof)
RustFS exposes a deterministic HTTP API (PUT|GET|DELETE /api/v1/buckets/{bucket}/objects/{key}). Scanner clients tag immutable uploads with X-RustFS-Immutable: true and, when retention applies, X-RustFS-Retain-Seconds: <ttlSeconds>. Additional headers can be injected via scanner.artifactStore.headers to support custom auth or proxy requirements. RustFS provides the standard S3-compatible interface for all artifact storage.
4) REST API (Scanner.WebService)
Endpoints are registered under the configured API base path (/api/v1 by default) in Program.cs; scan-scoped routes live in Endpoints/ScanEndpoints.cs and its sub-mappers. Auth: OpTok (DPoP/mTLS); per-endpoint scope policies (see §10). The route table below is the authoritative core surface; the WebService also registers many additional endpoint groups (see note after the table).
# Scan lifecycle (ScanEndpoints.cs) — scans segment configurable, default /scans
POST /scans { image:{reference|digest} | artifactRef/artifactDigest, force?, clientRequestId?, metadata? }
→ 202 Accepted { scanId, status, location, created } # scope: scanner.api (any-of scanner:scan/write/...)
GET /scans/{scanId} → 200 { scanId, status, image, createdAt, updatedAt, failureReason?, entropy?, surface?, replay?, attestation? } # scanner.scans.read
POST /scans/{scanId}/entropy → 202 (attach layer entropy snapshot) # scanner.scans.write
GET /scans/{scanId}/events → SSE/JSONL progress stream # scanner.scans.read
GET /scans/{scanId}/entrytrace → 200 EntryTrace graph + best plan # scanner.scans.read
GET /scans/{scanId}/ruby-packages → 200 { scanId, imageDigest, generatedAt, packages[] } # scanner.scans.read
GET /scans/{scanId}/bun-packages → 200 { scanId, imageDigest, generatedAt, packages[] } # scanner.scans.read
# SBOM submit + export (SbomEndpoints.cs / ExportEndpoints.cs), per scan
POST /scans/{scanId}/sbom (CycloneDX 1.7/1.6, SPDX, JSON body) → 202 { sbomId, format, componentCount, digest } # scanner.scans.write
GET /scans/{scanId}/exports/sbom ?format=spdx2|spdx3|cyclonedx&profile=software|lite|build|security (default SPDX 2.3; content-negotiated CycloneDX-XML / SPDX-3 JSON-LD) # scanner.scans.read
GET /scans/{scanId}/exports/sarif → application/sarif+json # scanner.scans.read
GET /scans/{scanId}/exports/cdxr → CycloneDX 1.7 with reachability # scanner.scans.read
GET /scans/{scanId}/exports/openvex → OpenVEX JSON # scanner.scans.read
GET /scans/{scanId}/exports/signed-sbom-archive ?compression=gzip|zstd → fail-closed (409 if no verified material, 422 if invalid) # scanner.scans.read
POST /scans/{scanId}/exports/signed-sbom-material (producer handoff; verifies then upserts) # scanner.signed-sbom-material.write
# Manifest / proofs (ManifestEndpoints.cs) — durable in scanner.scan_manifest / scanner.proof_bundle
GET /scans/{scanId}/manifest # scanner.scans.read
GET /scans/{scanId}/proofs # scanner.scans.read
GET /scans/{scanId}/proofs/{rootHash} # scanner.scans.read
# Score replay (ScoreReplayEndpoints.cs; gated by ScoreReplay.Enabled). Legacy /score/{scanId}/* aliases retained.
POST /scans/{scanId}/score/replay # scanner.scans.write GET /scans/{scanId}/score/bundle # scanner.scans.read
POST /scans/{scanId}/score/verify # scanner.scans.write GET /scans/{scanId}/score/history # scanner.scans.read
# Reachability (ReachabilityEndpoints.cs), per scan
POST /scans/{scanId}/compute-reachability → 202 (501 until a durable reachability backend is wired) # scanner.scans.write
GET /scans/{scanId}/reachability/components|findings|explain # scanner.scans.read
GET /scans/{scanId}/reachability/traces/export → 200 (sensor_gap or callable_path_proof, never 501) # scanner.scans.read
# Reports (ReportEndpoints.cs) — delegates to Policy preview; returns signed report DTO inline
POST /reports { imageDigest, findings?, baseline? } → 200 { report, dsse?, attestation } # scanner.reports
(503 when no policy snapshot)
GET /reports/artifact-vulnerabilities?subject=<artifactRef|digest>&digest=<optional>
→ 200 ArtifactVulnerabilityReport, 404 when no tenant-scoped persisted report exists # scanner.reports
The artifact vulnerability report resolver is the canonical Scanner-owned
source for audit-bundle vulnerability material keyed by artifact. The resolver
uses the tenant-scoped latest row in `scanner.scan_runtime_state` for an
`artifactRef` and/or digest, preferring digest when both are supplied, then reads
the persisted public reachability verdicts for that scan's image digest. It
returns schema `stellaops.scanner.artifact-vulnerability-report.v1` with
`source = scanner.public-reachability-verdicts`, the resolved `scanId`,
`scanStatus`, `scanUpdatedAt`, summary counts, and the artifact-scoped findings
array. It does not synthesize findings from policy previews, placeholder CVEs,
or tenant-wide findings lists.
Absent data is an expected control-plane outcome: unknown artifacts,
cross-tenant artifacts, scans without persisted reachability verdicts, and
unavailable reachability storage return `404`/null rather than placeholder JSON
or a `500`. Callers must pass the authenticated tenant context and must preserve
that null behavior when building audit bundles.
# SBOM upload (BYOS) + hot lookup + evidence (SbomUploadEndpoints.cs and siblings)
POST /sbom/upload, GET /sbom/uploads/{sbomId} # see byos-ingestion.md
# ProofSpine (ProofSpineEndpoints.cs)
GET /spines/{spineId} GET /scans/{scanId}/spines
# Registry lookup (RegistryEndpoints.cs, group prefix /registries) — 501 Not Implemented until a registry integration is configured
GET /registries/images/search GET /registries/images/digests # scanner.scans.read
# Tenant Scanner settings (ScannerTenantSettingsEndpoints.cs)
GET /scanner/settings -> 200 { disabledAnalyzerIds[], etag, updatedAt?, updatedBy? } # scanner:read | scanner.admin
PUT /scanner/settings + If-Match -> 200 updated settings; 412 stale; 428 missing precondition # scanner:write | scanner.admin
GET /scanner/analyzers -> 200 { sourceStatus, observedAt?, analyzers[] } # scanner:read | scanner.admin
GET /scanner/performance -> 200 { window, metrics, omittedMetrics[] } # scanner:read | scanner.admin
# Health / build / observability (not under the API base path)
GET /healthz | /readyz | /buildinfo.json | observability + offline-kit routes
Beyond the core surface above, Program.cs also maps these endpoint groups (see endpoint-registration-matrix.md for the full inventory and scopes): sources + SCM bulk import (/sources, /scm/...), webhooks, delta-compare / smart-diff / baseline / actionables / counterfactual, replay, witness, EPSS, triage (/triage/inbox, clusters, status, proof bundles, batch actions), /unknowns, secret-detection settings + secret bundles, scan-policy CRUD, policy preview (feature-gated), runtime ingest, reachability stack + evidence, advanced-assurance QA fixtures, GitHub code-scanning, validation, fidelity, layer SBOM, call graph, approvals, evidence, slice, VEX-reachability filter (/scans/vex-reachability/filter), and the VEX gate read endpoints (currently 501). Baseline recommendation/rationale route shapes are also reserved but currently return deterministic HTTP 501 scanner_baseline_recommendations_unavailable: the prior in-memory implementation synthesized digests, release versions, verdicts, and timestamps and is intentionally unwired until Scanner owns persisted, tenant-scoped known-good baseline facts. There is no legacy GET /sboms/{imageDigest}, GET /diff, POST /exports, or GET /catalog/artifacts/{id} route — those were superseded by the per-scan export routes, the smart-diff/delta-compare endpoints, and the catalog being internal to Storage.
Source create/update keeps the pre-flight tenant/name uniqueness check for clear operator feedback, and also translates late PostgreSQL 23505 violations on uq_sbom_sources_tenant_name into the existing 409 scanner.sources.already_exists / update-conflict endpoint responses when concurrent writers race.
See docs/modules/scanner/byos-ingestion.md for BYOS workflow, formats, and troubleshooting.
4.0 Tenant settings contract
GET|PUT /api/v1/scanner/settings is a tenant-scoped operator surface with split least-privilege policies: scanner.operations.read accepts canonical scanner:read (or legacy scanner.admin) for GET, while scanner.operations.write accepts canonical scanner:write (or legacy scanner.admin) for PUT. This keeps Console reads reachable and gives explicitly provisioned writers an audited update path without granting the deliberately unseeded escape-hatch admin scope. The default stella-ops-ui and professional interactive role bundles omit scanner:write, so their settings view remains read-only. The first frozen contract exposes one real pipeline control: the normalized, sorted list of admitted signed analyzer IDs that Scanner Worker must skip. It does not expose registry credentials, host paths, global plug-in admission controls, or configuration-only values that the scan pipeline ignores.
The strong response ETag is a SHA-256 hash of the canonical settings document. PUT requires exactly one strong If-Match; a stale compare-and-swap returns 412, while a missing precondition returns 428. Successful updates emit a Timeline audit event containing actor, tenant, timestamp, and before/after analyzer IDs and ETags. Storage is the auto-migrated scanner.tenant_settings table. Worker resolves the queue job tenant and filters matching signed OS/language analyzer IDs before dispatch; a settings repository failure is not swallowed into an unsafe enabled-by-default run. The complete request, response, validation, and error contract is scanner-tenant-settings-api.md.
GET /api/v1/scanner/analyzers reads Scanner Worker’s canonical signed-plugin probe report from the existing shared scanner-plugin-scratch volume and overlays the current tenant’s disabled IDs. It maps accepted/responded evidence to healthy, explicit rejected/failed evidence to unhealthy, and all absent or incomplete signals to unknown; it never synthesizes a degraded state. lastSuccessfulProbeAt is the report observation time only for accepted/responded rows. Scanner does not persist per-analyzer last successful scan execution, so no such timestamp is claimed. Missing or invalid reports return sourceStatus=unavailable; tenant-disabled IDs remain visible as unknown so operators can reverse them. The frozen response contract is scanner-analyzers-api.md.
GET /api/v1/scanner/performance is a small tenant-scoped read model over durable Scanner records, not browser-side Prometheus scraping. Current pending depth is counted from scanner.scan_runtime_state rows in Pending; duration p50/p95 is derived from non-replay scanner.scan_metrics.total_duration_ms rows in the requested window. A missing duration population omits the duration object instead of returning zero. Windowed failure rate remains omitted because the runtime-state model does not persist an immutable terminal outcome timestamp. The contract and supported windows are frozen in scanner-performance-api.md.
4.1 Manifest and proof persistence contract
- 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.
4.1.1 Forward-only migration 011 cutover
Migration 011_sprint20260719_byos_upload_tenant_isolation.sql intentionally replaces the historical global sbom_id primary key with (tenant_id, sbom_id). That change is required for tenant isolation, but it is not backward-compatible with a historical Scanner Web instance whose BYOS upsert used sbom_id as its only conflict identity. Do not leave an old scanner-web serving while a new service applies migration 011.
The safe single-instance cutover sequence is:
- Freeze coherent Scanner Worker and Scanner Web images from the same source candidate. Drain Router traffic to the old
scanner-web, then stop it. - Recreate the new
scanner-workerfirst. Its Scanner Storage startup runner must apply migrations 010 and 011 before the Worker becomes the accepted candidate. - Verify the migration ledger and PostgreSQL catalog:
scanner.tenant_settingsexists;scanner.sbom_uploads.tenant_idisNOT NULL; and the table primary key columns are exactly(tenant_id, sbom_id). Also verify the Worker image identity, healthy/restart-zero state, clean startup/migration logs, and a current analyzerprobe-report.jsononscanner-plugin-scratch. - Recreate the matching new
scanner-web, verify its exact image identity, healthy/restart-zero state and Router re-registration, then restore traffic. Authenticated gateway reads of/api/v1/scanner/settings,/api/v1/scanner/analyzers, and/api/v1/scanner/performanceare the minimum read-only API forcing checks.
Scanner migrations are forward-only. After migration 011, prefer rolling forward to the coherent new Worker/Web pair. Do not restart an old Scanner image against the upgraded schema. A true rollback requires the pre-migration PostgreSQL snapshot plus the coordinated old image pair while Router traffic remains drained; a schema-only downgrade is not supported. A cross-tenant same-SBOM live probe additionally creates durable acceptance records, so run it only with dedicated tenants and explicit retention/cleanup authority. The disposable-PostgreSQL regression remains the non-mutating proof for ordinary deployments.
- 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.
- 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. - 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. - Scanner treats signed SBOM archives and offline-kit imports as transparency-positive only after the local Rekor public key verifies the signed checkpoint, the RFC 6962 Merkle path recomputes the checkpoint root, and the Rekor receipt is bound to the DSSE envelope bytes. UUIDs, log indexes, unsigned checkpoints, placeholder certificates, JWS headers, or Fulcio issuer strings 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.
- Cross-module fixture coverage now verifies the proof-shape boundary in
src/VexLens/__Tests/StellaOps.VexLens.Tests/Verification/CrossModuleTransparencyFixtureTests.cs: Scanner-shaped DSSE/Fulcio/Rekor material verifies only through the DSSE trust-root path and fails closed as compact JWS input, while VexLens compact JWS/JWKS/revocation material verifies only through the JWS path and fails closed as DSSE input. - 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. 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).
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 Redis Stream entries (scanner.report.ready, scanner.scan.completed) to the configured stream (default stella.events). The stream fields carry the whole envelope plus lightweight headers (kind, tenant, ts) so Notify and UI timelines can consume the event bus without recomputing signatures. Publish timeouts and bounded stream length are controlled via scanner:events:publishTimeoutSeconds and scanner:events:maxStreamLength. If the queue driver is already Redis and no explicit events DSN is provided, the host reuses the queue connection and auto-enables event emission so deployments get live envelopes without extra wiring. Compose/Helm bundles expose the same knobs via the SCANNER__EVENTS__* environment variables for quick tuning.
5) Execution flow (Worker)
5.1 Acquire & verify
- 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, sbom-match)
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 and forward the emitted PURLs to Concelier’s matcher.
pull-layers(PullLayersStageExecutor, StageName => pull-layers):
- Reads
image.reference(orimage.digest) from lease metadata. - 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).
A manifest not found in the configured registry persists the bounded reason pull failed: image was not found in the configured registry; use a fully qualified reference or mirror the image. The submitted reference and registry remain only in structured worker logs.
build-filesystem(BuildFilesystemStageExecutor, StageName => build-filesystem):
- 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.
sbom-match(SbomMatchStageExecutor, StageName => sbom-match, runs right after execute-analyzers):
Collects the component PURLs the analyzers emitted (from
analysis.layers.fragments) and forwards the scan’s image digest + PURLs to Concelier’sPOST /api/v1/learn/sbomso the realSbomAdvisoryMatcherruns against the canonical advisory database and writesvuln.sbom_registry+vuln.sbom_canonical_match. Keying the registration by the image digest lets the read-model attribute findings to the scan’s build/release image.Authenticates as the Worker service with a tenant-scoped client-credentials bearer (scope
advisory:ingest) whenScanner:Worker:Authority:Enabled=true. Concelier’s/learn/sbomis guarded by.RequireTenant(), whose resolver (StellaOpsTenantResolver) is claim-only — it reads thestellaops:tenantclaim and ignores tenant headers and the request body. The Worker therefore mints one token per scan tenant (viaConcelierLearnTokenHandler, which requests a client-credentials token with thetenantparameter so Authority embeds the matchingstellaops:tenantclaim) and selects it per-request fromConcelierLearnTenantContext. The scan’s tenant is taken from canonical lease metadata (tenant,tenantId, orstellaops.tenant), falling back toMatcher:DefaultTenant; legacy header-shaped metadata such asx-tenant-idis ignored. A canonicalX-StellaOps-TenantIdheader and the bodytenantIdare also sent for audit/log parity. Best-effort: a forward failure is logged but does not fail an otherwise-good scan (the SBOM is already produced + projected). Config-gated byScanner:Worker:Pipeline:Matcher:Enabled(default on). This does not reimplement matching — it mirrors the BYOSSbomLearnForwarderpattern.The same
stellaops-scanner-workerservice client is reused for the other Scanner/SBOM worker forwards, but not for broad CLI automation. Its Authority grant is intentionally narrow:advisory:ingestfor learn,advisory:read aoc:verifyfor the reachability canonical-advisory read path,integration:read integration:operatefor integrations credential resolution,scanner:scanfor ReachGraph publish, andrelease:read release:writefor release-evidence publish.
Configuration (Scanner:Worker:Pipeline, env prefix SCANNER_)
| Key | Env | Default | Purpose |
|---|---|---|---|
DefaultRegistry | SCANNER__SCANNER__WORKER__PIPELINE__DEFAULTREGISTRY | stellaops-registry:5000 | Registry used when a ref omits one; a single-label host equal to this host is treated as the registry. The in-cluster registry listens on :5000 (not the implicit http :80), so the default carries the explicit port and a bare stellaops-registry/library/... ref also resolves to :5000. |
AllowInsecure | …__PIPELINE__ALLOWINSECURE | true | Plain-HTTP / non-TLS access for the internal stellaops-registry (worker has no docker.io egress in the air-gapped posture). |
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:Enabled | …__PIPELINE__MATCHER__ENABLED | true | Forward emitted PURLs to Concelier /learn/sbom. |
Matcher:LearnUrl | …__PIPELINE__MATCHER__LEARNURL | http://concelier.stella-ops.local/api/v1/learn/sbom | Gateway-routed learn endpoint. |
Matcher:DefaultTenant | …__PIPELINE__MATCHER__DEFAULTTENANT | default | Tenant used when lease metadata carries none. |
An image reference without a registry, such as nginx:alpine, resolves against DefaultRegistry; it does not imply Docker Hub. In the default air-gapped profile the operator must mirror the image into stellaops-registry:5000 or submit a fully qualified reference to an explicitly configured reachable registry.
When AllowInsecure=true, Scanner treats any loopback IP literal as a local HTTP registry, not only localhost and 127.0.0.1. This keeps local proof registries such as 127.1.1.5:80 on HTTP for both image inspection and OCI artifact pushes. Upload Location headers are resolved relative to the started upload URI, so registries that return relative upload locations preserve the selected HTTP/HTTPS scheme. OCI descriptor fields with no value are omitted rather than serialized as JSON null, matching registries that validate descriptor schemas strictly.
OS analyzer plug-in packaging. The base mounted Scanner profile carries APK, DPKG, and RPM as canonical runtime bundles under devops/plugins/scanner/base/stellaops.scanner.plugin.os.{apk,dpkg,rpm}. Each bundle contains the OS analyzer DLL payload, a DSSE-signed ScannerPluginManifest (manifest.json) generated from the tracked analyzer project manifest source, and checksums.sha256 for admission inventory. The signed manifest IDs are stellaops.scanner.plugin.os.apk, stellaops.scanner.plugin.os.dpkg, and stellaops.scanner.plugin.os.rpm, with purl-prefix contracts matching current emission (pkg:alpine/, pkg:deb/, pkg:rpm/).
As of commit 5a47e7e665 (2026-06-07, WS1) the legacy unsigned OS plug-in path has been hard-cut: the worker scan execution path no longer activates OS analyzers through OsAnalyzerPluginCatalog (deleted, along with the trust-less IOSAnalyzerPlugin contract it loaded via PluginHost.LoadPlugins with no DSSE admission). OS analyzers are now sourced from ScannerOsAnalyzerHost, which reuses the language-analyzer gold-standard ScannerPluginLoader DSSE / trust-store / assembly-SHA-256 admission gate via the new signed IScannerOsAnalyzerPlugin contract (detailed immediately below). CompositeScanAnalyzerDispatcher, Program.cs, and ScannerAnalyzerProbeReportService all consume the signed host. All 12 OS analyzer plug-ins (apk/dpkg/rpm + homebrew/macosbundle/pacman/pkgutil/ windows.chocolatey/windows.msi/windows.winsxs + portage) implement the new contract, and the 7 non-base OS bundles are produced + DSSE-verified. Missing/untrusted OS bundles still cause the worker No OS analyzer plug-ins discovered … scanning will skip OS package extraction and emits only pkg:generic/* native-binary PURLs — the SBOM→matcher path then produces 0 canonical matches for the scanned image.
Scanner OS analyzer signed contract (IScannerOsAnalyzerPlugin). Commit 5a47e7e665 added IScannerOsAnalyzerPlugin (src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Plugin/IScannerOsAnalyzerPlugin.cs) as the signed-bundle counterpart to IScannerLanguageAnalyzerPlugin. It exposes Id / DisplayName / Version / IsAvailable(IServiceProvider) / CreateAnalyzer(IServiceProvider) and returns the existing IOSPackageAnalyzer, so every behavioral contract is preserved: OsComponentFragments, the busybox / musl reachability heuristics, the os-package component type, and the OsPackageAnalyzers analysis key. Id must equal the manifest id (ordinal), and Version must equal the manifest version.
ScannerOsAnalyzerHost (src/Scanner/StellaOps.Scanner.Worker/Plugins/ScannerOsAnalyzerHost.cs, wired via AddScannerOsAnalyzerHost in ScannerOsAnalyzerHostExtensions) hosts the contract on the same ScannerPluginLoader admission path the language analyzers use: it scans each immediate sub-directory of the OS plugin root for a signed manifest.json, verifies the DSSE/EdDSA signature against the trust store, checks the assembly SHA-256 and signed StellaOps.Scanner.Analyzers.OS version/digest stamp, then activates the declared entry type. ScannerOsAnalyzerHostOptions mirrors ScannerAnalyzerHostOptions and exposes the fail-closed switches RequirePluginDirectory, RequireTrustStoreDirectory, RequireTrustedKeys, and RequireLoadedAnalyzers (all default false, set true by the overlay). The host default signature verifier is Loader’s Ed25519AsymmetricSignatureVerifier (which delegates non-EdDSA algorithms to DefaultAsymmetricSignatureVerifier.Instance) because the in-tree OS manifests are DSSE-signed with EdDSA.
Tests: 11 OS-host admission tests (every reject path admits zero analyzers) + the dispatcher OS tests rewired to the new host (every behavioral assertion preserved, none weakened) + 7 new AllAnalyzerManifestsLoadTests forcing-functions. The base scanner bundles were restored byte-identical (devops/plugins/scanner tree clean).
Opt-in compose overlay: devops/compose/docker-compose.plugins.scanner-os.yml mounts the signed OS analyzer bundle root (devops/plugins/scanner/analyzers/os/* → /app/plugins/scanner/analyzers/os) and the Scanner plugin trust store (devops/etc/certificates/trust-roots/plugins/scanner → /app/trust-roots/plugins/scanner) read-only onto both scanner-web and scanner-worker, points ScannerAnalyzerHost__PluginDirectory / ScannerAnalyzerHost__TrustStoreDirectory at those mounts, and sets all four Require*=true switches so unsigned/untrusted analyzer bundles are rejected (supply-chain boundary). Apply with COMPOSE_EXTRA_FILES=docker-compose.plugins.scanner-os.yml ./scripts/compose-cli.ps1 up. Bundle producer: devops/build/package-runtime-plugins.ps1 -Module scanner (signed analyzer bundles, OS profile).
Note: the lang analyzers were already signed and load through the same
ScannerPluginLoader; this slice closes the OS gap so all analyzer categories share one signed admission path.Live runtime probe is pending. The contract, host, dispatcher rewire, producer blocks, and overlay are committed (
5a47e7e665), but a live overlay-up acceptance probe against a running scanner (mount the signed OS bundles, scan an image, confirm OS-package PURLs + admitted rows on/internal/plugins/probe) has not yet been recorded.
Language analyzer bundle packaging. Generic language analyzers load through ScannerAnalyzerHost and the DSSE-verified ScannerPluginLoader. The approved base language bundle is dotnet, golang, java, node, and python; the optional full overlay adds bun, ccpp, dart, deno, elixir, php, ruby, rust, and swift, yielding the complete Bun, Ccpp, Dart, Deno, DotNet, Elixir, Go, Java, Node, Php, Python, Ruby, Rust, and Swift language set. The full overlay mounts that complete set at /app/plugins/scanner/analyzers; the E2E analyzer-coverage override uses the same mounted full bundle while keeping its relaxed synthetic, secrets, and native testing settings out of base compose. Each language bundle is an immediate child of plugins/scanner/analyzers/ and contains a signed manifest.json, the analyzer assembly, transitive DLLs/deps/runtime files, and analyzer-owned static data. The manifest assembly.sha256 must match the assembled DLL bytes. Its signed metadata must also match the Worker’s current StellaOps.Scanner.Analyzers.Lang, LangAdapter, and Plugin.Contracts assemblies byte-for-byte. package-runtime-plugins.ps1 -Module scanner -Profile scanner-runtime publishes these analyzers from current source and produces both base and full profiles, including the complete published runtime dependency closure for every OS analyzer. build-service-publish.sh scanner-worker invokes that step after the Worker publish, overlays those exact contract assemblies into the image context, and then builds the service image. Production trust roots belong under etc/scanner/trust-store/scanner-plugins, mounted read-only by operators; the current worker startup keeps the dev trust-store path as an explicit compatibility override until SCN-PLUG-002 moves bundle assembly/signing out of service images. Once compose mounts the base bundle, deployments should enable ScannerAnalyzerHost__RequirePluginDirectory=true, ScannerAnalyzerHost__RequireTrustStoreDirectory=true, ScannerAnalyzerHost__RequireTrustedKeys=true, and ScannerAnalyzerHost__RequireLoadedAnalyzers=true to fail closed on missing or invalid analyzer bundles.
5.3 Evidence harvest (parallel analyzers; deterministic only)
A) OS packages
- 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}.
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. The implementation parser project remains the source of the richer native analyzer code, 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/Trivy payloads. - Reachability lattice: analyzers + runtime probes emit
Evidence/Mitigationrecords (seedocs/modules/reach-graph/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 BinaryIndex catalog and disassembly pipeline described below.
E) Binary Vulnerability Lookup (Sprint 20251226_014_BINIDX)
The BinaryLookupStageExecutor enriches scan results with binary-level vulnerability evidence:
- Identity Extraction: For each ELF/PE/Mach-O binary, extract Build-ID, file SHA256, and architecture. Generate a
binary_keyfor catalog lookups. - Build-ID Catalog Lookup: Query the BinaryIndex known-build catalog using Build-ID as primary key. Returns CVE matches with high confidence (>=0.95) when the exact binary version is indexed.
- Fingerprint Matching: For binaries not in the catalog, compute position-independent fingerprints (basic-block, CFG, string-refs) and match against the vulnerability corpus. Returns similarity scores and confidence.
- Fix Status Detection: For each CVE match, query distro-specific backport information to determine if the vulnerability was fixed via distro patch. Methods:
changelog,patch_analysis,advisory. - Valkey Cache: All lookups are cached with configurable TTL (default 1 hour for identities, 30 minutes for fingerprints). Target cache hit rate: >80% for repeat scans.
- Runtime wiring (2026-02-12):
BinaryLookupStageExecutornow publishes unified mapped findings toanalysis.binary.findings, Build-ID to PURL lookup results toanalysis.binary.buildid.mappings, and patch verification output toanalysis.binary.patchverification.result.
BinaryFindingMapper converts matches to standard findings format with BinaryFindingEvidence:
public sealed record BinaryFindingEvidence
{
public required string BinaryKey { get; init; }
public string? BuildId { get; init; }
public required string MatchMethod { get; init; } // buildid_catalog, fingerprint_match, range_match
public required decimal Confidence { get; init; }
public string? FixedVersion { get; init; }
public string? FixStatus { get; init; } // fixed, vulnerable, not_affected, wontfix
}
Proof Segments: The Attestor generates binary_fingerprint_evidence proof segments with DSSE signatures for each binary with vulnerability matches. Schema: https://stellaops.dev/predicates/binary-fingerprint-evidence@v1.
UI Badges: Scan results display status badges:
- Backported & Safe (green): Distro backported the fix
- Affected & Reachable (red): Vulnerable and in code path
- Unknown (gray): Could not determine status
CLI Commands (Sprint 20251226_014):
stella binary inspect <file>: Extract identity (Build-ID, hashes, architecture)stella binary lookup <build-id>: Query vulnerabilities by Build-IDstella binary fingerprint <file>: Generate position-independent fingerprint
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.
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, CLI toggles, and binary analysis conventions.
5.5.4 Build provenance verification (Sprint 20260119_019)
When an SBOM includes CycloneDX formulation or SPDX build profile data, the worker runs the build-provenance stage to verify provenance completeness, builder trust, source integrity, hermetic build requirements, and optional reproducibility checks. The report is attached as a surface observation payload (build-provenance.report) and keyed in the analysis store for policy enforcement and audit evidence. Self-asserted SBOM parameters such as provenanceSigned=true, builderAttestationSigned=true, or commitSigned=true are not cryptographic proof and do not satisfy signed provenance, signed source, or SLSA level promotion requirements unless a verifier has validated the signature material. See src/Scanner/docs/build-provenance.md for policy schema, CLI toggles, and report formats.
5.5.5 SBOM dependency reachability (Sprint 20260119_022)
When configured, the worker runs the reachability-analysis stage to infer dependency reachability from SBOM graphs and optionally refine it with a richgraph-v1 call graph. Advisory matches are filtered or severity-adjusted using VulnerabilityReachabilityFilter, with false-positive reduction metrics recorded for auditability. The stage attaches:
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.
The committed baseline stack leaves dependency reachability off by default. Operators enable it through the opt-in reachability overlay (or equivalent runtime configuration), and any non-harness host with Scanner:Worker:Reachability:Enabled=true must also configure Scanner:Worker:Reachability:AdvisoryBaseUrl to the Concelier canonical advisory service root. ScannerReachabilityRuntimeConfigurationValidator fails startup without that advisory URL so the local NullSbomAdvisoryMatcher cannot produce empty CVE evidence that looks like a clean scan. The existing live forcing-function is recorded at docs/qa/feature-checks/runs/reachability-live-forcing-function/RUN_REPORT.md and exercises this opt-in lane with the overlay layered.
If a local harness intentionally runs the null advisory matcher, the dependency reachability report marks summary.advisorySourceStatus=unavailable with summary.advisorySourceReason=advisory-source-unconfigured, and the surface manifest copies those fields into reachability.report metadata. A zero-CVE count is only a clean advisory result when the advisory source status is available.
The Worker publishes the rich reachability graph before PoE generation. PoE generation consumes the real RichGraphPublishResult plus explicit build, image, policy, and config metadata; missing or invalid graph hashes, build IDs, sha256 image digests, policy IDs/digests, config paths, or empty DSSE signing output fail closed with typed poe_* outcomes instead of emitting placeholder proof records.
PoE DSSE signing is not local by default. When PoE:Enabled=true, the Worker registers the PoE stage only after binding PoE:Signer:Provider=http-signer, which calls Signer POST /api/v1/signer/sign/dsse with the emitted PoE JSON as the predicate. The request includes:
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.
Local development can exercise the same Worker-to-Signer path with devops/compose/docker-compose.scanner-poe-dev.yml and docs/modules/scanner/operations/poe-dev-harness.md. The harness computes the local stellaops/scanner-worker:dev image digest, configures Signer’s dev entitlement registry and trusted Scanner digest list, requests a DPoP-bound signer:sign token from the dev scanner-poe-dev Authority client, generates a fresh Signer DPoP proof with ath, and starts the real signer plus scanner-worker services with PoE:Enabled=true. The up path starts Signer, posts a fixed DSSE request to the real Signer endpoint, asserts a non-empty envelope and signature, and only then starts Scanner Worker. This remains a dev harness: release pipelines must provide registry manifest digests, environment-specific Authority clients, DPoP key custody, and deployment-managed secret distribution.
5.5.6 VEX decision filter with reachability (Sprint 20260208_062)
Scanner now exposes a deterministic VEX+reachability matrix filter for triage pre-processing:
- Gate matrix component:
StellaOps.Scanner.Gate/VexReachabilityDecisionFilterevaluates(vendorStatus, reachabilityTier)and returns one ofsuppress,elevate,pass_through, orflag_for_review. - Matrix examples:
(not_affected, unreachable) -> suppress,(affected, confirmed|likely) -> elevate,(not_affected, confirmed|likely) -> flag_for_review. - API surface:
POST /api/v1/scans/vex-reachability/filteraccepts finding batches and returns annotated decisions plus action summary counts. - Determinism: batch order is preserved, rule IDs are explicit, and no network lookups are required for matrix evaluation.
- Lifecycle boundary: callers supply the already-resolved VEX status and reachability tier. The endpoint does not query VexLens, mutate or persist findings, or create a durable audit record;
matrixRuleandrationaleare response annotations. A future durable audit workflow must first own authenticated tenant/scan identity and an audit store rather than treating this stateless endpoint as lifecycle authority.
The read-side VEX gate endpoints (VexGateController, route base api/v1/scans: GET /api/v1/scans/{scanId}/gate-results, /{scanId}/gate-summary, and /{scanId}/gate-blocked, plus the GET /api/v1/scans/gate-policy read) no longer bind to a canonical in-memory store in live runtime. Until Scanner grows a durable VEX gate results backend, gate-results returns 501 Not Implemented with error = "vex_gate_results_unsupported" instead of inventing transient state.
5.5.7 Vulnerability-first triage clustering APIs (Sprint 20260208_063)
Scanner triage now includes deterministic exploit-path clustering primitives for vulnerability-first triage workflows:
- 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 action shape:
POST /api/v1/triage/inbox/clusters/{pathId}/actionsrequests one action for all findings in the cluster and returns a deterministic payload digest. The currentTriageStatusServicedoes not persist a decision, lane update, or snapshot, andBatchTriageActionRecord.Signedisfalse; this route is not yet durable or attestable workflow authority. - Offline/determinism posture: no network calls, stable ordering by IDs/path IDs, deterministic path-ID hashing, and replayable batch payload digests.
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
ScannerRequestContextResolver(canonical claimtenant, compatibility claim aliases, compatibility header aliases, and conflict detection). - 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 currently has no explicit ScannerPolicies.ScansRead authorization metadata. Client-side scanner:read preflight is a usability guard, not a server security boundary; adding endpoint enforcement remains a Scanner hardening follow-up before these routes can be treated as an authenticated production contract. Tenant-scoped Triage DB evidence and gating rows are always resolved before an enabled development fixture, so a real finding with the same identity wins. The Triage EF runtime model schema-qualifies those relations under scanner; reads do not depend on a deployment-specific PostgreSQL search_path. Missing unified evidence remains a typed 404 scanner.triage.finding_not_found without echoing the requested fixture-looking identity, and never falls back in the default or Production posture.
Every enabled fixture payload is visibly labeled isDemo=true, including vulnerability items/details, gating status/bucket summaries, and unified evidence. Real responses retain isDemo=false. The fixture remains entirely offline and deterministic, but its identifiers (asset-worker-prod, repeating UUIDs, demo-*, previous-demo-scan, and stella-demo-signer) are forbidden from production responses.
5.5.9 Unknowns API tenant activation (Sprint 20260222_057 follow-up)
Scanner now registers the /api/v1/unknowns endpoint group in Program.cs with explicit scanner.scans.read authorization and tenant-aware query semantics:
- Request tenant resolution uses
ScannerRequestContextResolverwith canonical/compatibility claim-header handling and deterministic conflict failures (tenant_conflict). - Unknown list/detail/evidence/history/stats/bands handlers call a tenant-scoped query service that filters by
tenant_id. - 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. - 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 (devops/plugins/scanner/catalog-packs/, mounted; 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/reach-graph/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)
Scanner.Sbomer.BuildXPlugin can act as a BuildKit generator:
- During
docker buildx build --attest=type=sbom,generator=stellaops/sbom-indexer, run analyzers on the build context/output; attach SBOMs as OCI referrers to the built image. - Optionally request Signer/Attestor to produce Stella Ops-verified attestation immediately; else, Scanner.WebService can verify and re-attest post-push.
- Scanner.WebService trusts build-time SBOMs per policy, enabling no-rescan for unchanged bases.
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"
postgres:
connectionString: "Host=postgres;Port=5432;Database=scanner;Username=stellaops;Password=stellaops"
s3:
endpoint: "http://rustfs:8080"
bucket: "stellaops"
objectLock: "governance" # or 'compliance'
analyzers:
# OS analyzers (apk/dpkg/rpm + pacman/portage; macOS homebrew/bundle/pkgutil; Windows msi/winsxs/chocolatey) and
# language analyzers are loaded as restart-time plug-ins; this map is illustrative of toggles, not the full set.
os: { apk: true, dpkg: true, rpm: true }
lang: { java: true, node: true, bun: true, deno: true, python: true, go: true, dotnet: true, rust: true, ruby: true, php: true, ccpp: true, dart: true, elixir: true, swift: true }
native: { elf: true, pe: true, macho: true } # PE and Mach-O parsers are implemented
entryTrace: { enabled: true, shellMaxDepth: 64, followRunParts: true }
emit:
cdx: { json: true, protobuf: true }
spdx: { json: true }
compress: "zstd"
rekor:
url: "https://rekor-v2.internal"
signer:
url: "https://signer.internal"
limits:
maxParallel: 8
perRegistryConcurrency: 2
policyHints:
verifyImageSignature: false
trustBuildTimeSboms: true
Worker PoE signing uses the top-level PoE section because the Worker binds it directly for startup validation:
PoE:
Enabled: true
SigningKeyId: "scanner-poe-prod-key"
Signer:
Provider: "http-signer"
Url: "https://signer.internal/"
ScannerImageDigest: "sha256:<64 hex scanner worker image digest>"
ProofOfEntitlementFormat: "jwt"
ProofOfEntitlement: "<deployment secret>"
DpopProof: "<sender-bound proof>"
# Use either BearerToken or Scanner:Worker:Authority client credentials.
BearerToken: "<optional deployment secret>"
SigningMode: "kms"
ReturnBundle: "dsse+cert"
9) Scale & performance
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. Scan submission (scanner.apipolicy) is an any-of policy acceptingscanner:scan/scanner:writeas well as the granularscanner.scans.enqueue/scanner.scans.writescopes; read paths acceptscanner:readorscanner.scans.read. 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,scanner.admin, and the offline-kit scopes (mapped toairgap:import/airgap:status:read). 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: all first-party images are cosign-signed; Workers/WebService self-verify at startup.
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.
11) Observability & audit
Metrics:
scanner.jobs_inflight,scanner.scan_latency_secondsscanner.layer_cache_hits_total,scanner.file_cas_hits_totalscanner.artifact_bytes_total{format}scanner.attestation_latency_seconds,scanner.rekor_failures_totalscanner_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])); alert when the rate is ≥ 1 for 15 minutes to highlight unexpected stripped binaries.
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
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/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. - 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.
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 probes (with Zastava): when allowed, compare /proc/
/maps (DSOs actually loaded) with static Usage view for precision.
15) DevOps & operations
- HA: WebService horizontal scale; Workers autoscale by queue depth & CPU; distributed locks on layers.
- Retention: ILM rules per artifact class (
short,default,compliance); Object Lock for compliance artifacts (reports, signed SBOMs). - Upgrades: bump cache schema when analyzer outputs change; WebService triggers refresh of dependent artifacts.
- Backups: PostgreSQL (pg_dump daily); RustFS snapshots (filesystem-level rsync/ZFS) or S3 versioning when legacy driver enabled; Rekor v2 DB snapshots.
16) CLI & UI touch points
- CLI:
stellaops scan <ref>,stellaops diff --old --new,stellaops export,stellaops verify attestation <bundle|url>. - 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
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 now expose/api/v1/scans/{scanId}/score/*as primary routes with/api/v1/score/{scanId}/*compatibility aliases.SCN-007closed: deterministic scoring now emits factorized vectors (cvss,epss,reachability,provenance) plus canonical input hash/payload metadata for replay verification.- 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/implplan/SPRINT_20260304_302_Scanner_trace_delta_and_actionables_completion.mddocs/implplan/SPRINT_20260304_303_Scanner_score_replay_contract_and_formula_alignment.md
