Scan Submission Flow
Reconciliation note (doc ↔ code). This flow was reconciled against the Scanner WebService and Worker source in
src/Scanner/. Several earlier sketches did not match the implementation and have been corrected here: the submit/status request and response contracts, the status enum, the queue-backed dispatch path (there is no separate “Scheduler” service in the submit path), the worker stage pipeline (the/internal/*orchestration endpoints never existed), the analyzer set, the storage table names, and the completion-notification mechanism. Ground truth:src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs,Contracts/ScanSubmitRequest.cs,Contracts/ScanSubmitResponse.cs,Contracts/ScanStatusResponse.cs,Domain/ScanStatus.cs,src/Scanner/StellaOps.Scanner.Worker/Processing/ScanStageNames.cs.
Overview
The Scan Submission Flow describes the lifecycle of a container image scan from initial submission through SBOM generation, vulnerability matching, VEX gating, verdict/attestation publication, and result storage. This is the core workflow that produces security evidence for container images.
Business Value: Automated, deterministic scanning ensures every container image is evaluated against the latest advisories and policies before deployment.
Actors
| Actor | Type | Role |
|---|---|---|
| Developer/CI | Human/System | Submits scan request |
| CLI / API Client | System | Initiates scan via API |
| Gateway | Service | Routes and authenticates |
| Scanner WebService | Service | Accepts submission, persists snapshot, enqueues job |
| Scan queue (Valkey) | Infrastructure | Buffers enqueued scan jobs |
| Scanner Worker | Service | Leases job, analyzes image, runs the stage pipeline |
| Concelier | Service | Provides advisory data / matcher ingestion (sbom-match stage) |
| VEX (VexLens / VexHub) | Service | VEX statements consumed by the vex-gate stage |
| Policy Engine | Service | Materialises effective findings / policy verdicts |
| Signer / Attestor | Service | Signs the verdict DSSE envelope (push-verdict / generate-poe stages) |
| JobEngine / Notifier | Service | Receives scan events and fans out notifications |
Note on “Scheduler”. Earlier drafts named a standalone Scheduler service in the submit path. The Scanner WebService submit endpoint enqueues directly onto a Valkey-backed scan queue (
QueuePublishingScanCoordinator); the Scanner Worker leases jobs from that queue (QueueBackedScanJobSource). The Scheduler module now lives inside JobEngine (see repo§1.4module map) and is not invoked per-submission. Scheduled/recurring scans are a separate concern.
Prerequisites
- Valid bearer token carrying a scan-submit scope —
scanner:scanorscanner:write(see Authorization Scopes) - Container registry accessible (or image already resolvable by digest)
- Registry credentials configured (if private registry)
Flow Diagram
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Scan Submission Flow │
└─────────────────────────────────────────────────────────────────────────────────┘
┌───────┐ ┌─────────┐ ┌───────────────┐ ┌────────┐ ┌─────────────────┐
│ CLI │ │ Gateway │ │ Scanner │ │ Scan │ │ Scanner Worker │
│ /API │ │ │ │ WebService │ │ queue │ │ (stage pipeline)│
└───┬───┘ └────┬────┘ └───────┬───────┘ └───┬────┘ └────────┬────────┘
│ │ │ │ │
│ POST /api/v1/scans │ │ │
│ {image:{reference,digest}} │ │ │
│───────────>│ │ │ │
│ │ authn + scope │ │ │
│ │ check │ │ │
│ │───────────────>│ │ │
│ │ │ persist │ │
│ │ │ snapshot + │ │
│ │ │ enqueue │ │
│ │ │──────────────>│ │
│ 202 Accepted │ │ │
│ {scanId,status:"Pending", │ │ │
│ location,created} │ │ │
│<───────────│<───────────────│ │ │
│ │ │ │ lease job │
│ │ │ │────────────────>│
│ │ │ │ │ resolve-image
│ │ │ │ │ pull-layers
│ │ │ │ │ build-filesystem
│ │ │ │ │ execute-analyzers
│ │ │ │ │ sbom-match ──> Concelier
│ │ │ │ │ reachability-analysis
│ │ │ │ │ epss-enrichment
│ │ │ │ │ vex-gate ──> VEX evidence
│ │ │ │ │ publish-evidence
│ │ │ │ │ compose-artifacts (SBOM)
│ │ │ │ │ emit-reports
│ │ │ │ │ generate-poe ──> Signer
│ │ │ │ │ push-verdict ──> OCI registry
│ │ │ │ │
│ GET /api/v1/scans/{scanId} │ │ │ (status polled
│───────────>│───────────────>│ │ │ or SSE stream)
│ 200 {status,image,...} │ │ │
│<───────────│<───────────────│ │ │
│ │ │ │ │
│ │ JobEngine event scanner.event.scan.completed │
│ │ (carries `notifier` metadata) ──> Notifier │
│ │ │ │ │
The named stages above are the canonical worker pipeline from
ScanStageNames.Ordered. Stages with no registered executor for a given deployment are silently skipped (ScanJobProcessor), so not every scan runs every stage.
Step-by-Step
1. Scan Request Submission
CLI: The CLI scan command group does not expose a single stellaops scan <image> one-shot submit. Local execution uses stellaops scan run (runs a scanner bundle against a local target), and stellaops scan image inspect|layers inspects an OCI image. Server-side submission is done by API clients / CI integrations against the endpoint below. (Verify against src/Cli/StellaOps.Cli/Commands/CommandFactory.cs, BuildScanCommand.)
API Request (POST /api/v1/scans, route group base /api/v1, segment scans):
POST /api/v1/scans HTTP/1.1
Host: gateway.stellaops.local
Authorization: Bearer {token-with-scanner:scan-or-scanner:write}
X-StellaOps-TenantId: acme-corp
Content-Type: application/json
{
"image": {
"reference": "docker.io/library/nginx:1.25",
"digest": "sha256:abc123..."
},
"force": false,
"clientRequestId": "ci-build-4821",
"metadata": {
"ci.pipeline": "build-and-scan"
}
}
Request contract (ScanSubmitRequest):
image.reference/image.digest— OR the flat aliasesartifactRef/artifactDigest. At least one of reference or digest is required; an empty body or one lacking both yields a400problem+json.force(bool) — re-run even if a snapshot already exists.clientRequestId(string) — caller-supplied idempotency/correlation hint.metadata(string→string map) — free-form; the service also injectsdeterminism.feed/determinism.policyfrom configured snapshot IDs when present.
There is no
optionsobject on the submit request. Analyzer selection, SBOM format, policy set, and attestation are not per-request fields — they are deployment/worker configuration. Earlier drafts that showedoptions.{analyzers,sbom_format,policy_set,attestation}were inaccurate.
2. Gateway Processing
- Authenticates the bearer token and enforces tenant resolution.
ScannerRequestContextResolveraccepts the canonicalX-StellaOps-TenantIdheader (the legacyX-Tenant-Idform is also honoured) and reconciles it against the token tenant claim; a conflict is rejected. - Enforces the submit scope policy (
scanner.api→ any-ofscanner:scan,scanner:write, plus the legacyscanner.scans.enqueue/scanner.scans.writeAuthority scopes). - Forwards to the Scanner WebService.
3. Submission + Enqueue (Scanner WebService)
HandleSubmitAsyncnormalises the target, resolves the tenant, and callsIScanCoordinator.SubmitAsync.- The persisted coordinator records a
ScanSnapshot;QueuePublishingScanCoordinatorthen enqueues the job onto the scan queue (Valkey-backed). - The response is returned immediately (async pattern) with
202 Acceptedand aLocationheader pointing at the status endpoint.
Response (ScanSubmitResponse, 202 Accepted):
{
"scanId": "9f3a9b2c1234abcd...",
"status": "Pending",
"location": "/api/v1/scans/9f3a9b2c1234abcd...",
"created": true
}
statusis the live snapshot status (Pendingon a fresh submit), serialised from theScanStatusenum. There is noestimated_waitfield, and the identifier is a 40-char hexscanId, not ascan-<uuid>string.
4. Worker Dispatch
The Scanner Worker leases the enqueued job (QueueBackedScanJobSource) and runs ScanJobProcessor.ExecuteAsync, which iterates the canonical stage pipeline (ScanStageNames.Ordered). There is no separate Scheduler service handing work to the worker, and no scheduler.jobs table is written during submit.
5. Stage Pipeline (Image Analysis → Verdict)
The worker executes stages in this fixed order (ScanStageNames.Ordered); stages without a registered executor are skipped:
Stage (StageName) | Purpose |
|---|---|
ingest-replay | Mount a sealed replay bundle when replaying a prior scan |
resolve-image | Resolve the image manifest / digest |
pull-layers | Fetch image layers |
build-filesystem | Materialise the layered filesystem |
checkout-source | (Source-scan path) check out source |
execute-analyzers | Run the OS + language analyzers (see note below) |
prepare-reachability-inputs | Stage inputs for reachability |
sbom-match | Forward component PURLs + image digest to Concelier matcher |
service-security, crypto-analysis, ai-ml-security, build-provenance | Optional deeper analyses |
scan-secrets, binary-lookup | Secret detection and binary symbol lookup |
epss-enrichment | Attach EPSS scores to findings |
reachability-analysis, synthetic-analyzer-coverage-reachability | Reachability signals |
vex-gate | Apply VEX evidence and compute the gate (pass / warn / block) |
publish-evidence | Project scan evidence |
compose-artifacts | Assemble the unified SBOM (CycloneDX / SPDX) |
entropy | Layer entropy / opacity analysis |
emit-reports | Emit the report document |
generate-poe | Generate the proof-of-evidence / DSSE material (Signer) |
push-verdict | Publish the verdict DSSE envelope as an OCI referrer artifact |
Analyzers. The repo ships 14+ language analyzers (
StellaOps.Scanner.Analyzers.Lang.*: C/C++, Swift, Dart, Elixir, Go, Rust, Bun, PHP, Java, .NET, Python, Node, Deno, Ruby) plus many OS package analyzers (Analyzers.OS.*: apk, dpkg, rpm, pacman, portage, homebrew, and Windows Chocolatey/MSI/WinSxS, macOS pkgutil/bundle). The earlier fixed list of exactly 11 named analyzers was outdated, and analyzer selection is not a submit-request input.
6. Vulnerability Matching (sbom-match stage)
After analyzers emit components, SbomMatchStageExecutor forwards the scan’s image digest and component PURLs to Concelier’s matcher (IConcelierLearnClient.ForwardAsync, keyed by the scan’s image digest, under the scan’s tenant). The resulting matches land in vuln.sbom_canonical_match and surface via the findings APIs. A matcher-forward failure is best-effort and does not fail an otherwise-good scan.
There is no Scanner
POST /internal/matchendpoint. Matching is an outbound worker→Concelier call, not an inbound HTTP API on the Scanner.
7. VEX Application (vex-gate stage)
VexGateStageExecutor consumes available VEX evidence and computes a gate result summarised in the scan-completed event’s vexGate block (totalFindings, passed, warned, blocked, bypassed, policyVersion). VEX status (e.g. not_affected, fixed) modifies whether a finding is gated.
There is no Scanner
POST /internal/vex/applyendpoint.
8. Policy / Verdict
Effective findings and policy verdicts are materialised by the Policy Engine (it holds the effective:write service scope and writes effective findings). The Scanner Worker’s push-verdict stage publishes the resulting verdict as a signed DSSE envelope OCI referrer artifact (VerdictPushStageExecutor → IVerdictOciPublisher), carrying the SBOM digest, feeds digest, policy digest, and decision.
There is no Scanner
POST /internal/evaluateendpoint, and the rich{verdict, confidence, violations[]}body shown previously is not a real Scanner API response. The verdict is surfaced via the OCI referrer artifact and the scan-completed event payload.
9. Attestation (generate-poe / push-verdict stages)
DSSE signing is performed through the Signer service (HttpSignerPoEDsseSigningService for proof-of-evidence; the verdict envelope is signed and pushed by the verdict-push stage). There is no Scanner POST /internal/attest endpoint; the doc previously showed a fabricated request shape.
10. Result Storage
Scanner stores results:
| Store | Data |
|---|---|
PostgreSQL scanner.scans (scans table) | Scan metadata / snapshot |
PostgreSQL scanner.scan_findings | Per-finding rows (scan_id, tenant_id, vuln_id, package_purl, …) |
| RustFS object store | SBOM documents, attestations, and other artifacts (RustFsArtifactObjectStore) |
| Valkey | Scan queue + progress/event streaming (not a long-term result cache) |
The findings table is
scan_findings, notfindings. Artifact blobs are stored via the configuredIArtifactObjectStore(RustFS by default; an S3-compatible store also exists) rather than fixedblobs//attestations/path conventions.
11. Notification
The Scanner does not call a Notify service directly, and there is no per-submit “Scheduler triggers Notify” step. On completion the worker / web service emits JobEngine events whose kinds are defined in JobEngineEventKinds (scanner.event.scan.completed, .started, .failed, scanner.event.report.ready, scanner.event.sbom.generated, scanner.event.vulnerability.detected). The scan-completed event carries a notifier metadata block (severityThresholdMet, notificationChannels, digestEligible, immediateDispatch, priority) which the Notifier consumes to decide fan-out.
Illustrative scanner.event.scan.completed envelope (JobEngineEvent + ScanCompletedEventPayload):
{
"eventId": "…",
"kind": "scanner.event.scan.completed",
"version": 1,
"tenant": "acme-corp",
"occurredAt": "2026-05-30T10:30:00Z",
"scope": { "repo": "library/nginx", "digest": "sha256:abc123..." },
"payload": {
"scanId": "9f3a9b2c…",
"imageDigest": "sha256:abc123...",
"verdict": "fail",
"summary": { },
"vexGate": { "totalFindings": 12, "passed": 9, "warned": 1, "blocked": 2 }
},
"notifier": { "severityThresholdMet": true, "immediateDispatch": true }
}
Data Contracts
Scan Submit Request (ScanSubmitRequest)
interface ScanSubmitRequest {
image?: { // ScanImageDescriptor
reference?: string; // registry/repo:tag or @digest
digest?: string; // sha256:...
};
artifactRef?: string; // flat alias for image.reference
artifactDigest?: string; // flat alias for image.digest
force?: boolean; // re-scan even if a snapshot exists
clientRequestId?: string; // caller correlation / idempotency hint
metadata?: Record<string, string>; // free-form string→string map
}
// At least one of reference/digest (in either form) is required; otherwise 400.
Scan Submit Response (ScanSubmitResponse, 202 Accepted)
interface ScanSubmitResponse {
scanId: string; // 40-char hex identifier
status: string; // ScanStatus: "Pending" | "Running" | "Succeeded" | "Failed" | "Cancelled"
location?: string; // status URL (also echoed in the Location header)
created: boolean; // false if an existing snapshot was reused
}
Scan Status Response (ScanStatusResponse, GET /api/v1/scans/{scanId})
interface ScanStatusResponse {
scanId: string;
status: string; // "Pending" | "Running" | "Succeeded" | "Failed" | "Cancelled"
image: { reference?: string; digest?: string }; // ScanStatusTarget
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
failureReason?: string;
entropy?: { // EntropyStatusDto, when computed
imageOpaqueRatio: number;
layers: { layerDigest: string; opaqueRatio: number; opaqueBytes: number; totalBytes: number }[];
};
surface?: object; // SurfacePointersDto, when a digest resolves
replay?: { // ReplayStatusDto, when artifacts exist
manifestHash: string;
bundles: { type: string; digest: string; casUri: string; sizeBytes: number }[];
};
}
The status response does not contain
verdict,confidence, a severitysummary, orsbom_url/attestation_url/findings_url. The verdict and finding summary are delivered out-of-band via thescanner.event.scan.completedJobEngine event (and the OCI verdict referrer artifact), not on the scan-status endpoint. The status enum isPending | Running | Succeeded | Failed | Cancelled(ScanStatus,Domain/ScanStatus.cs) — noteSucceeded(notcompleted) and the absence of aqueuedvalue.
Error Handling
Submit-time errors (returned synchronously from POST /api/v1/scans):
| Error | HTTP Status | Source |
|---|---|---|
| Missing/empty body, or neither reference nor digest supplied | 400 (problem+json) | HandleSubmitAsync validation |
Digest supplied but not a valid algo:hex digest | 400 (problem+json) | ArtifactDigestValidator |
| Tenant could not be resolved / tenant conflict | 400 (problem+json) | ScannerRequestContextResolver |
Missing or insufficient scope (scanner:scan / scanner:write) | 401 / 403 | Authorization policy scanner.api |
| Duplicate / conflicting submission | 409 | Declared on the submit endpoint |
Runtime errors (surfaced asynchronously, not on the submit response): registry pull/auth failures, analyzer failures, and signing failures are reported through the scan snapshot status/failureReason and the scanner.event.scan.failed event (ScanFailedEventPayload carries a structured error with code, message, and a recoverable flag). The earlier table conflated synchronous submit errors with downstream runtime failures, and listed a “Policy set not found” case that does not apply (policy set is not a submit input).
Observability
Metrics
Worker metrics (ScannerWorkerMetrics, meter ScannerWorkerInstrumentation.Meter):
| Metric | Type |
|---|---|
scanner_worker_queue_latency_ms | Histogram |
scanner_worker_job_duration_ms | Histogram |
scanner_worker_stage_duration_ms | Histogram |
scanner_worker_jobs_completed_total | Counter |
scanner_worker_jobs_failed_total | Counter |
scanner_worker_language_cache_hits_total / ..._misses_total | Counter |
scanner_worker_os_cache_hits_total / ..._misses_total | Counter |
scanner_worker_surface_manifests_published_total (and _skipped / _failed) | Counter |
The earlier
scan_submitted_total/scan_completed_total/scan_duration_secondsnames were not found in the worker metrics source; the emitted names arescanner_worker_*as above. WebService-side counters (if any) should be confirmed separately.
Trace Context
scan-submission
├── gateway-auth
├── scanner-webservice-submit (persist snapshot + enqueue)
└── scanner-worker-execute (ScanStageNames.Ordered)
├── resolve-image
├── pull-layers
├── build-filesystem
├── execute-analyzers
├── sbom-match (→ Concelier matcher)
├── reachability-analysis
├── epss-enrichment
├── vex-gate
├── publish-evidence
├── compose-artifacts (SBOM assembly)
├── emit-reports
├── generate-poe (→ Signer)
└── push-verdict (→ OCI registry referrer)
Authorization Scopes
Canonical scope constants live in src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs. Scopes relevant to this flow:
| Scope (claim value) | Constant | Used for |
|---|---|---|
scanner:scan | ScannerScan | Trigger a scan (submit) |
scanner:write | ScannerWrite | Also accepted for submit; entropy attach |
scanner:read | ScannerRead | Read scan status, events, SBOM, reports |
scanner:export | ScannerExport | Export SBOM / reports |
effective:write | EffectiveWrite | Policy Engine service identity writing effective findings |
signer:sign | SignerSign | DSSE signing of verdict / PoE material |
ASP.NET policy names in
ScannerPolicies(e.g.scanner.api,scanner.scans.read) are distinct from the scope claim values above. The submit policy (scanner.api) is an any-of overscanner:scan,scanner:write, and the legacy Authority scopesscanner.scans.enqueue/scanner.scans.write(Program.cs, Sprint20260517_020).
Related Flows
- SBOM Generation Flow - Detailed SBOM creation
- Policy Evaluation Flow - Reachability-lattice evaluation details
- CI/CD Gate Flow - Pipeline integration
