SBOM Generation Flow
Overview
The SBOM Generation Flow describes how Stella Ops creates Software Bills of Materials for container images using its language-specific analyzers, OS package analyzers, and a native (ELF) analyzer. The flow covers layer extraction, dependency detection, transitive resolution, and final SBOM assembly in SPDX 3.0.1 or CycloneDX 1.7 format.
Source of truth:
src/Scanner/. As of writing the repository ships 14 language analyzers (src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.*), a set of OS package analyzers (StellaOps.Scanner.Analyzers.OS.*), and a native ELF analyzer (StellaOps.Scanner.Analyzers.Native). Analyzers are loaded by the Scanner Worker as DSSE-signed plug-ins; they are not separate services. Verify the analyzer roster againstsrc/before relying on counts here.
Business Value: Complete, accurate SBOMs enable precise vulnerability matching and regulatory compliance (Executive Order 14028, EU CRA).
Actors
| Actor | Type | Role |
|---|---|---|
| Scanner WebService | Service | Accepts scan/SBOM submissions, exposes results (src/Scanner/StellaOps.Scanner.WebService) |
| Scanner Worker | Service | Runs the staged scan pipeline that produces the SBOM (src/Scanner/StellaOps.Scanner.Worker) |
| Layer Extractor | Stage | Unpacks OCI layers (PullLayersStageExecutor, BuildFilesystemStageExecutor, LayerArchiveExtractor) |
| OS Package Analyzers | Plug-ins | Identify base OS packages (apk, dpkg, rpm, pacman, portage, homebrew, macOS bundle/pkgutil, Windows MSI/Chocolatey/WinSxS) |
| Language Analyzers (14) | Plug-ins | Detect ecosystem-specific dependencies |
| Native Analyzer | Plug-in | Extracts ELF symbols / call-graph for reachability (StellaOps.Scanner.Analyzers.Native) |
| SBOM Composer | Component | Merges fragments into CycloneDX / SPDX (StellaOps.Scanner.Emit composers) |
| Signer / Attestor | Service | Produces DSSE-signed surface manifests, PoE artifacts, and verdict attestations |
Prerequisites
- Container image accessible (registry or local)
- Registry credentials if private registry (resolved via the Scanner’s registry/secret stages)
- Analyzer plug-ins present and trusted: each analyzer is a DSSE-signed plug-in loaded by
ScannerAnalyzerHostfrom the plug-in directory and verified against a trust store ofkeyId → SubjectPublicKeyInfoanchors (ScannerPluginLoaderOptions.TrustedKeys). The signature algorithm is read from each manifest entry and resolved viaSignatureAlgorithmName.FromString; supported tokens are RS256 (the default when unspecified), PS256, ES256, and EdDSA/Ed25519 (reserved for forward compatibility). SettingAllowUnsignedloads manifests without a signature block (dev only; defaults tofalsefor fail-secure posture).
Language Analyzers
Stella Ops ships 14 language analyzers under src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.*. The table below lists the manifest and lock files each analyzer keys on; verify the column values against the analyzer source (e.g. *BuildFileDiscovery, *LockFileCollector, *Parser) — the lock/manifest distinction below is a summary, not an exhaustive list.
| Analyzer | Ecosystems | Manifests (declared) | Lock / pinned files |
|---|---|---|---|
| DotNet | NuGet, .NET | *.csproj, *.fsproj, *.vbproj, Directory.Build.props, Directory.Packages.props, packages.config, global.json | packages.lock.json, *.deps.json |
| Java | Maven, Gradle | pom.xml, build.gradle, build.gradle.kts, embedded MANIFEST.MF / pom.xml inside *.jar | gradle.lockfile |
| Node | npm, yarn, pnpm | package.json | package-lock.json, yarn.lock, pnpm-lock.yaml |
| Python | pip, Poetry, Pipenv | pyproject.toml, setup.py | requirements.txt, poetry.lock, Pipfile.lock |
| Go | Go Modules | go.mod, go.work | go.sum, vendor/modules.txt, plus build-info embedded in Go binaries |
| Rust | Cargo | Cargo.toml | Cargo.lock |
| PHP | Composer | composer.json | composer.lock |
| Ruby | Bundler | Gemfile | Gemfile.lock |
| Deno | Deno, JSR | deno.json, import_map.json | deno.lock |
| Bun | Bun | package.json | bun.lockb |
C/C++ (Ccpp) | Native C/C++ build inputs | — | — |
| Swift | Swift / Mach-O | Package.swift | Package.resolved |
| Dart | Dart / Flutter (pub) | pubspec.yaml | pubspec.lock |
| Elixir | Mix / Hex | mix.exs, Erlang .app files | mix.lock |
The native binary analyzer (
StellaOps.Scanner.Analyzers.Native) is separate from the language analyzers above. It parses ELF binaries (symbol tables, build IDs) to build a reachability call-graph; PE and Mach-O are handled within the .NET and Swift analyzers respectively rather than by a single unified “Binary” analyzer.
OS Package Analyzers
In addition to language analyzers, the Scanner ships OS package analyzers (StellaOps.Scanner.Analyzers.OS.*) that enumerate distro-managed packages:
| Analyzer | Source |
|---|---|
| apk | Alpine package database |
| dpkg | Debian/Ubuntu dpkg status |
| rpm | RPM database (RHEL/Fedora/SUSE) |
| pacman | Arch Linux |
| portage | Gentoo |
| homebrew | macOS Homebrew |
| macOS bundle / pkgutil | macOS app bundles and installer receipts |
| Windows MSI / Chocolatey / WinSxS | Windows installed-product and side-by-side assemblies |
The Scanner also ships a Secrets analyzer (
StellaOps.Scanner.Analyzers.Secrets, run by the worker’s separatescan-secretsstage) and a build-info/SBOM indexer (StellaOps.Scanner.Sbomer.BuildXPlugin). The Secrets analyzer detects leaked credentials and does not contribute components to the SBOM; it is out of scope for this flow.
Flow Diagram
Simplified sequence. In the implementation the Scanner Worker drives an ordered stage pipeline (
ScanStageNames.Ordered) —resolve-image → pull-layers → build-filesystem → checkout-source → execute-analyzers → … → compose-artifacts → emit-reports → generate-poe → push-verdict. The language/OS/native analyzers run as in-process signed plug-ins duringexecute-analyzers, not as separate downstream services.
┌─────────────────────────────────────────────────────────────────────────────────┐
│ SBOM Generation Flow │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────┐ ┌───────────┐ ┌────────────┐ ┌───────────────────────────────┐
│ Scanner │ │ Layer │ │ OS │ │ Language Analyzers │
│ │ │ Extractor │ │ Detector │ │ (DotNet, Java, Node, etc.) │
└────┬────┘ └─────┬─────┘ └──────┬─────┘ └───────────────┬───────────────┘
│ │ │ │
│ Fetch image │ │ │
│ manifest │ │ │
│──────┐ │ │ │
│ │ │ │ │
│<─────┘ │ │ │
│ │ │ │
│ Extract │ │ │
│ layers │ │ │
│─────────────>│ │ │
│ │ │ │
│ │ Unpack to │ │
│ │ work dir │ │
│ │───────┐ │ │
│ │ │ │ │
│ │<──────┘ │ │
│ │ │ │
│ │ Layers ready │ │
│<─────────────│ │ │
│ │ │ │
│ Detect OS │ │ │
│──────────────────────────────>│ │
│ │ │ │
│ │ │ Parse /etc/os-release │
│ │ │ Check package manager │
│ │ │──────────┐ │
│ │ │ │ │
│ │ │<─────────┘ │
│ │ │ │
│ OS: Alpine 3.19 │ │
│<──────────────────────────────│ │
│ │ │ │
│ Fan-out to │ │ │
│ all analyzers│ │ │
│────────────────────────────────────────────────────────>│
│ │ │ │
│ │ │ │ ┌─────────┐
│ │ │ │ │ DotNet │
│ │ │ │ └────┬────┘
│ │ │ │ │
│ │ │ Scan *.csproj
│ │ │ Parse deps.json
│ │ │ │<─────┘
│ │ │ │
│ │ │ │ ┌─────────┐
│ │ │ │ │ Java │
│ │ │ │ └────┬────┘
│ │ │ │ │
│ │ │ Parse pom.xml
│ │ │ Run gradle deps
│ │ │ │<─────┘
│ │ │ │
│ │ │ │ ┌─────────┐
│ │ │ │ │ Node │
│ │ │ │ └────┬────┘
│ │ │ │ │
│ │ │ Parse lockfiles
│ │ │ Build dep tree
│ │ │ │<─────┘
│ │ │ │
│ │ │ ... (more analyzers)
│ │ │ │
│ Analyzer │ │ │
│ results │ │ │
│<────────────────────────────────────────────────────────│
│ │ │ │
│ ┌────────────────┐ │ │
│ │ SBOM Assembler │ │ │
│ └───────┬────────┘ │ │
│ │ │ │
│ Merge & │ │ │
│ dedupe │ │ │
│──────────> │ │
│ │ │ │
│ SBOM │ │ │
│ document │ │ │
│<─────────│ │ │
│ │ │ │
│ ┌─────────┐ │ │
│ │Attestor │ │ │
│ └────┬────┘ │ │
│ │ │ │
│ Sign │ │ │
│───────> │ │
│ │ │ │
│ DSSE │ │ │
│<──────│ │ │
│ │ │ │
Step-by-Step
1. Image Manifest Fetch
Scanner retrieves OCI image manifest:
GET /v2/library/nginx/manifests/1.25 HTTP/1.1
Host: registry-1.docker.io
Accept: application/vnd.oci.image.manifest.v1+json
Response contains layer digests:
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"digest": "sha256:config123..."
},
"layers": [
{"digest": "sha256:layer1...", "size": 31456789},
{"digest": "sha256:layer2...", "size": 1234567}
]
}
2. Layer Extraction
Layer Extractor downloads and unpacks each layer:
work/
├── layer-0/ # Base OS layer (Alpine, Debian, etc.)
│ ├── etc/
│ ├── lib/
│ └── usr/
├── layer-1/ # Application layer
│ ├── app/
│ │ ├── node_modules/
│ │ ├── package.json
│ │ └── package-lock.json
│ └── ...
└── merged/ # Union filesystem view
3. OS Detection
OS detection and OS-package enumeration are performed by the OS package analyzers (StellaOps.Scanner.Analyzers.OS.*) during analyzer execution; the table below summarizes the signals they key on:
| Detection Method | Files Checked |
|---|---|
/etc/os-release | ID, VERSION_ID |
/etc/alpine-release | Alpine version |
/etc/debian_version | Debian version |
| Package manager | apk, dpkg, rpm |
Result:
{
"os": "alpine",
"version": "3.19",
"package_manager": "apk",
"architecture": "amd64"
}
4. Analyzer Execution
The Worker’s execute-analyzers stage runs entry-trace plus the analyzer dispatcher over the built filesystem; each loaded analyzer plug-in contributes a fragment. Examples (see the full roster in Language Analyzers):
DotNet Analyzer
Scanning for:
- *.csproj, *.fsproj, *.vbproj
- *.deps.json (runtime dependencies)
- packages.lock.json (NuGet lock)
- Directory.Packages.props (central management)
Output:
{
"packages": [
{"purl": "pkg:nuget/Newtonsoft.Json@13.0.3", "scope": "runtime"},
{"purl": "pkg:nuget/Microsoft.Extensions.Logging@8.0.0", "scope": "runtime"}
]
}
Java Analyzer
Scanning for:
- pom.xml (Maven)
- build.gradle, build.gradle.kts (Gradle)
- *.jar in lib/ directories
- MANIFEST.MF inside JARs
Output:
{
"packages": [
{"purl": "pkg:maven/com.google.guava/guava@32.1.2-jre", "scope": "compile"},
{"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.9", "scope": "runtime"}
]
}
Node Analyzer
Scanning for:
- package.json + package-lock.json (npm)
- yarn.lock (Yarn)
- pnpm-lock.yaml (pnpm)
- node_modules/.package-lock.json
Output:
{
"packages": [
{"purl": "pkg:npm/express@4.18.2", "scope": "runtime"},
{"purl": "pkg:npm/lodash@4.17.21", "scope": "runtime"}
]
}
5. Transitive Resolution
Each analyzer resolves transitive dependencies:
express@4.18.2
├── accepts@1.3.8
│ ├── mime-types@2.1.35
│ │ └── mime-db@1.52.0
│ └── negotiator@0.6.3
├── body-parser@1.20.1
│ ├── bytes@3.1.2
│ └── ...
└── ...
6. SBOM Assembly
The StellaOps.Scanner.Emit composers (CycloneDxComposer, SpdxComposer) merge all analyzer fragments:
- Deduplication: Remove duplicate PURLs across analyzers
- Relationship mapping: Build component dependency graph
- Metadata enrichment: Add licenses, hashes, supplier info
- Format conversion: Output as CycloneDX 1.7 or SPDX 3.0.1 (
SpdxDefaults.SpecVersion = "3.0.1")
The CycloneDX composer serializes with the bundled library’s
SpecificationVersion.v1_6and then upgrades the emitted document to 1.7 (CycloneDx17Extensions.UpgradeJsonTo17/UpgradeXmlTo17) becauseSpecificationVersion.v1_7is not yet supported upstream. Emitted media types are thereforeapplication/vnd.cyclonedx+json|+xml|+protobuf; version=1.7. The composer emits two views: anInventoryview (all components) and aUsageview (only components reached by an entrypoint), each available as JSON, XML, or Protobuf. A composition recipe and a Merkle root over the fragments are recorded for deterministic replay.
{
"$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"version": 1,
"metadata": {
"timestamp": "2024-12-29T10:30:00Z",
"tools": [{"name": "stellaops-scanner", "version": "<scanner-version>"}],
"component": {
"type": "container",
"name": "docker.io/library/nginx",
"version": "1.25"
}
},
"components": [
{
"type": "library",
"bom-ref": "pkg:npm/express@4.18.2",
"name": "express",
"version": "4.18.2",
"purl": "pkg:npm/express@4.18.2",
"hashes": [{"alg": "SHA-256", "content": "abc123..."}]
}
],
"dependencies": [
{
"ref": "pkg:npm/express@4.18.2",
"dependsOn": ["pkg:npm/accepts@1.3.8", "pkg:npm/body-parser@1.20.1"]
}
]
}
7. SBOM Attestation
DSSE signing happens inside the Worker pipeline rather than as a post-hoc external call: the compose-artifacts stage DSSE-signs the surface manifest (IDsseEnvelopeSigner), the generate-poe stage signs Proof-of-Exposure artifacts via the Signer (HttpSignerPoEDsseSigningService), and the push-verdict stage publishes verdicts as OCI referrer artifacts. A representative SBOM in-toto statement (predicate detail below is illustrative):
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "docker.io/library/nginx",
"digest": {"sha256": "abc123..."}
}
],
"predicateType": "https://spdx.dev/Document",
"predicate": {
"sbom": "base64-encoded-sbom...",
"generator": "stellaops-scanner@<scanner-version>",
"timestamp": "2024-12-29T10:30:00Z"
}
}
Signed with DSSE:
{
"payloadType": "application/vnd.in-toto+json",
"payload": "base64-encoded-statement...",
"signatures": [
{
"keyid": "sha256:signer-key-fingerprint",
"sig": "base64-encoded-signature..."
}
]
}
Authorization & Scopes
Gotcha (repo-wide): the ASP.NET policy name the Scanner WebService enforces is in dot-form, while the canonical scope claim in
StellaOpsScopesis colon-form. The Scanner accepts either form for read/write because the policies are registered as any-of. Always checksrc/Scanner/StellaOps.Scanner.WebService/Program.csandSecurity/ScannerPolicies.csfor the live mapping.
| Operation | Endpoint | Policy (dot-form) | Accepted scope claims |
|---|---|---|---|
| Upload (bring-your-own) SBOM | POST /api/v1/sbom/upload | scanner.scans.write | scanner.scans.write or scanner:write |
| Read uploaded SBOM record | GET /api/v1/sbom/uploads/{sbomId} | scanner.scans.read | scanner.scans.read or scanner:read |
| Read scan SBOM evidence | GET /api/v1/sbom/{scanId}/evidence | scanner.scans.read | scanner.scans.read or scanner:read |
| Write signed-SBOM material | (signed-SBOM material endpoints) | scanner.signed-sbom-material.write | scanner.signed-sbom-material.write (single scope, not any-of) |
Routes verified against
SbomUploadEndpoints.cs/SbomEvidenceEndpoints.csand the default API base path/api/v1(ScannerWebServiceOptions.Api.BasePath). The upload handler takes a JSONSbomUploadRequestDto(base64-encoded SBOM insbomBase64plus an optionalformathint); it does not use content-type negotiation.ScansRead/ScansWriteare registered as any-of (dot-form or colon-form);SignedSbomMaterialWriteis a single-scope policy.
Canonical scopes related to SBOM generation/consumption in StellaOpsScopes.cs: scanner:read, scanner:scan, scanner:export, scanner:write, scanner.signed-sbom-material.write, and the SBOM document scopes sbom:read, sbom:write, sbom:attest.
Data Contracts
Analyzer Output Schema
interface AnalyzerOutput {
analyzer: string;
ecosystem: string;
packages: Array<{
purl: string;
name: string;
version: string;
scope: 'runtime' | 'dev' | 'optional';
locations: string[];
hashes?: Record<string, string>;
licenses?: string[];
}>;
relationships: Array<{
parent: string;
child: string;
type: 'depends-on' | 'dev-depends-on';
}>;
}
SBOM Output Formats
Emitted by the StellaOps.Scanner.Emit composers:
| Format | Schema Version | Use Case |
|---|---|---|
| CycloneDX | 1.7 | Default; rich dependency graph (JSON, XML, and Protobuf serializers) |
| SPDX | 3.0.1 | Regulatory compliance, legal (JSON-LD) |
Ingest vs. emit. The Scanner emits CycloneDX 1.7 (serialized as 1.6, upgraded to 1.7) and SPDX 3.0.1; there is no SPDX 2.x emitter in
src/Scanner/__Libraries/StellaOps.Scanner.Emit. The “bring-your-own-SBOM” upload endpoint (POST /api/v1/sbom/upload) is more permissive on ingest:SbomByosUploadService.ValidateFormataccepts CycloneDX 1.4–1.6 and SPDX 2.3 and 3.0 (note SPDX 2.3 is accepted on ingest even though it is never emitted). Format/version are detected by parsing the JSON body (bomFormat/spdxVersion), with an optionalformatfield as an override — there is no content-type negotiation. CycloneDX 1.7 is not currently accepted on upload despite being the emit version; verify the supported range againstSbomByosUploadService.IsSupportedCycloneDx/IsSupportedSpdxbefore relying on it.
Error Handling
| Error | Recovery |
|---|---|
| Layer download failed | Retry with exponential backoff |
| Analyzer timeout | Mark analyzer as partial, continue |
| Lock file parse error | Fall back to manifest parsing |
| Invalid PURL | Log warning, skip component |
| Attestation failed | Return SBOM without attestation |
Observability
NOT IMPLEMENTED as named below. The metric and log-event names in the tables below are illustrative and do not match what the Scanner emits today — none of
sbom_generation_duration_seconds,sbom_analyzer_duration_seconds,sbom_components_total,sbom_size_bytes, or thesbom.*log events exist insrc/Scanner/. The implemented worker instruments use millisecond-suffixed names such asscanner_worker_job_duration_ms,scanner_worker_stage_duration_ms,scanner_worker_secrets_analysis_duration_ms, andscanner_worker_surface_manifest_publish_duration_ms. Verify live names against the worker meters (e.g.StellaOps.Scanner.Worker) before wiring dashboards/alerts.
Metrics (illustrative — see note above)
| Metric | Type | Labels |
|---|---|---|
sbom_generation_duration_seconds | Histogram | image_size, layer_count |
sbom_analyzer_duration_seconds | Histogram | analyzer |
sbom_components_total | Counter | analyzer, ecosystem |
sbom_size_bytes | Histogram | format |
Key Log Events (illustrative — see note above)
| Event | Level | Fields |
|---|---|---|
sbom.generation.start | INFO | image, digest |
sbom.analyzer.complete | DEBUG | analyzer, package_count |
sbom.assembly.complete | INFO | total_components, format |
sbom.attestation.signed | INFO | digest, keyid |
Related Flows
- Scan Submission Flow - Parent flow
- Binary Delta Attestation Flow - Binary-level analysis
- Evidence Bundle Export Flow - SBOM packaging
