SbomService — audit-bundle SBOM endpoint
Status: implemented. Verified against commit
942a18727c(2026-09-26). History: STORAGE + ENDPOINT-A shipped indocs-archive/implplan/SPRINT_20260610_003_SbomService_audit_bundle_sbom_endpoint.md; ENDPOINT-B (SBOM-VULN-PROXY-001) indocs-archive/implplan/SPRINT_20260612_001_SbomService_vuln_audit_proxy_enablement.md. Original consumer contract:docs-archive/implplan/HANDOFF_SbomService_audit_bundle_endpoints.md. The audit-bundle consumer formerly hosted by ExportCenter now runs in the OfflineKit host (offlinekit-web, librarysrc/OfflineKit/__Libraries/StellaOps.ExportCenter.Application/AuditBundle/). Its configuration keys keep theExportCenter:prefix.
Why
Audit bundles (POST /api/offlinekit/v1/audit-bundles on offlinekit-web, downloaded as a ZIP) include, per subject (an artifact), an sbom/cyclonedx.json entry and a reports/vuln-report.json entry. The SBOM/vuln entries used to be fabricated (hard-coded CVE-2023-12345 / empty CycloneDX shell) — hollow-impl finding #4, removed in commit 6063a14dda. The bundle job fetches real data through ISbomAuditSource/IVulnAuditSource (HttpSbomAuditSource, HttpVulnAuditSource), which return null when SbomService has no data, so the bundle omits the section and never fakes it.
Hard rule: when no data exists, the source returns null and the bundle omits the section. Never return placeholder/sample data.
Endpoint A — raw CycloneDX by subject (real)
GET /api/v1/sbom/subject/{subjectRef}/cyclonedx?version={versionId|latest}
Auth: Bearer token or Router-signed identity envelope
Required scope: sbom:read
subjectRef(route, URL-encoded by caller, decoded withUri.UnescapeDataString) is the artifact ref — the same value space as the existingGET /api/v1/sbom/uploads/{artifactRef}.version(optional, defaultlatest) selects a specificversion_id;latest= highestsequence_number.- 200 OK —
Content-Type: application/jsonby default, orapplication/vnd.cyclonedx+jsonwhen the caller sendsAccept: application/vnd.cyclonedx+json; body = the first-writer raw CycloneDX JSON root verbatim. PostgreSQL supplies the stored TEXT as UTF-8 bytes through a sequential reader, and the handler sends the owned bytes withResults.Bytes. There is no JSON serializer or UTF-16 string round trip. - 404 — no SBOM for that subject/tenant. This is the expected common case (the audit bundle omits the SBOM section); it must NOT 500 and must NOT fabricate.
- 400 — blank/malformed
subjectRef. 401 — missing/invalid authentication. 403 — missingsbom:read.
The handler resolves the ledger version in two steps (no single SQL join — the document lives in a separate sidecar repository): ISbomLedgerRepository.GetTenantScopedVersionAsync(artifactRef, tenantId, versionId?) returns the tenant-scoped version row, then ISbomDocumentRepository.GetDocumentByDigestAsync(SbomDocumentAccess) fetches the verbatim document. SbomDocumentAccess can be constructed only from a ledger version that carries a tenant, so a bare digest cannot reach the sidecar. The resolver’s SQL is the tenant-scoped equivalent of the conceptual join:
-- GetTenantScopedVersionAsync (latest; ?version=<id> adds AND version_id = @version_id)
SELECT ... , tenant_id
FROM sbom.ledger_versions
WHERE artifact_ref = @ref AND tenant_id = @tenant
ORDER BY sequence_number DESC, created_at_utc DESC
LIMIT 1;
-- then: SELECT document, media_type FROM sbom.sbom_documents WHERE digest = @digest
Tenant isolation (security-critical)
The pre-existing upload/version-history path had no tenant scoping: sbom.ledger_versions had no tenant_id column and repository lookups filtered only on artifact/version/digest. The authenticated-boundary sprint closes that gap across uploads, timelines, ledger reads, diffs, lineage, and change traces:
- Migration
004_ledger_versions_tenant_id.sqladdstenant_id TEXT; migration005_ledger_versions_tenant_chain.sqlreplaces the global artifact/sequence uniqueness rule with tenant-local chain sequencing. - The upload path captures the canonical authenticated
stellaops:tenantclaim and threads it throughSbomUploadService.UploadAsync(request, tenantId, ct)→SbomLedgerSubmission.TenantId→SbomLedgerVersion.TenantId→ theledger_versions.tenant_idcolumn. - All public ledger and lineage lookups use tenant-aware repository methods. A row with a NULL
tenant_id(legacy upload before this column existed) never matches a specific tenant — the safe default (no back-fill, no leak); such rows simply 404.
A subject uploaded under tenant A returns 404 when queried with a valid tenant B identity (proven by SbomSubjectCycloneDxEndpointTests.Get_CrossTenant_Returns404). Raw tenant headers cannot switch identity. Shipping the endpoint without this boundary would be a cross-tenant SBOM leak.
Same-named artifacts in different tenants receive independent chain IDs and sequence numbers; cross-tenant parent version/digest references are rejected.
Endpoint B - vuln/scan report by subject (Scanner proxy)
GET /api/v1/scan/results?subject={subjectRef}&digest={optionalDigest}
Auth: Bearer token or Router-signed identity envelope
Required scope: sbom:read
SbomService keeps the audit-bundle-facing Endpoint B URL stable and proxies to Scanner’s canonical artifact vulnerability report endpoint:
GET /api/v1/reports/artifact-vulnerabilities?subject={artifactRefOrDigest}&digest={optionalDigest}
Headers: x-tenant-id: <tenant>
Behavior:
- 200 OK - body is the raw Scanner report JSON returned by Scanner, forwarded without DTO reshaping, synthetic findings, or schema translation. The report schema is
stellaops.scanner.artifact-vulnerability-report.v1and the source isscanner.public-reachability-verdicts. - 404 - Scanner 404, Scanner 204/empty, unknown artifacts, cross-tenant artifacts, or artifacts with no persisted reachability verdicts map to SbomService 404. The audit-bundle source treats this as
nulland omitsreports/vuln-report.json. - 400 - blank
subjecton the SbomService request. Scanner validation errors are forwarded as upstream non-success responses with Scanner’s body when present. - 503 - Scanner base URL is rejected by
SbomService:ScannerHttpallowlists, the Scanner request times out, or Scanner is unreachable. This is a non-placeholder problem response, never a fabricated vulnerability report.
Tenant propagation is load-bearing: SbomService reads only the authenticated stellaops:tenant claim. Its current Scanner compatibility client forwards that trusted tenant through Scanner’s internal x-tenant-id contract; any actor value originates from the authenticated principal, not the incoming raw headers. A tenant B identity for a tenant A artifact therefore reaches Scanner as tenant B and must return 404/null.
Configuration:
SbomService:ScannerUrlselects the Scanner base URL (default local dev value ishttp://localhost:5100).SbomService:ScannerHttp:Timeout,AllowedHosts, andAllowedSchemesgate outbound Scanner calls.
The Scanner upstream contract is:
GET /api/v1/reports/artifact-vulnerabilities?subject={artifactRefOrDigest}&digest={optionalDigest}
Tenant: authenticated Scanner tenant context
Auth: scanner.reports.read or scanner:read
Scanner resolves the latest tenant-scoped scanner.scan_runtime_state row for the artifact ref/digest and returns schema stellaops.scanner.artifact-vulnerability-report.v1 from source scanner.public-reachability-verdicts. Unknown artifacts, cross-tenant artifacts, scans without persisted reachability verdicts, and unavailable reachability storage return 404/null rather than fabricated JSON. The exact Scanner contract is documented in docs/modules/scanner/architecture.md.
The enablement record is docs-archive/implplan/SPRINT_20260612_001_SbomService_vuln_audit_proxy_enablement.md (SBOM-VULN-PROXY-001 proxy proof; EXPORT-VULN-AUDIT-002 bundle inclusion/omission proof).
Storage model — sbom.sbom_documents sidecar
The raw uploaded document was originally ephemeral (parsed in memory for the digest, then discarded). It is now persisted in a digest-keyed sidecar table:
CREATE TABLE IF NOT EXISTS sbom.sbom_documents (
digest TEXT PRIMARY KEY,
media_type TEXT NOT NULL,
document TEXT NOT NULL, -- raw GetRawText() bytes; TEXT, never jsonb
byte_length INTEGER NOT NULL,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Design rationale:
- Sidecar, not a column on
sbom.ledger_versions— the ledger is a hot table (4 indexes, read on every version-history/diff/lineage/retention query). A multi-MB document column would bloat every row read. Raw payload reads serve tenant-fenced exports and the configured dependency backfill. Production-answer availability reads only sidecar headers. - Not the
IContentAddressedBlobStore— that store is only conditionally registered (whenSbomService:LineageExport:StorageRootPathis set); depending on it would make Endpoint A fail on deployments that haven’t configured it. A first-class DB table converges unconditionally on startup. document TEXT, neverjsonb—jsonbre-normalizes whitespace and re-orders keys, breaking the byte-for-byte contract (and any signature/attestation over the raw bytes). The raw text is stored and returned verbatim.- Content-addressed dedup — keyed on
digest(the value the upload already computes, identical toledger_versions.digest), written withINSERT … ON CONFLICT (digest) DO NOTHING. Identical re-uploads share one document row.
The document is persisted at upload time before AddVersionAsync(...) writes the ledger row (in SbomUploadService.UploadAsync, right after the digest is computed), so a version row never points at a missing sidecar document. ISbomDocumentRepository provides UpsertDocumentAsync, GetDocumentByDigestAsync and the payload-free GetMetadataByDigestAsync. Both reads require the same tenant-derived SbomDocumentAccess grant. The Postgres and in-memory implementations are wired alongside the ledger repository in the durable and LocalHarness branches.
Digest vs. verbatim bytes (implementation note). SbomUploadDigest.Compute streams the existing compact JSON serialization into SHA-256; its identity is unchanged from JsonSerializer.SerializeToUtf8Bytes(..., WriteIndented:false). The sidecar instead retains JsonMarshal.GetRawUtf8Value(root). Uploads whose compact JSON bytes are identical share one digest and sidecar row even when their whitespace differs; the first writer’s raw root wins. “Byte-for-byte” refers to that parsed JSON root, excluding the transport wrapper, base64 representation and whitespace outside the root. UTF-8 binary parameters and convert_from/convert_to keep the existing TEXT column and its exact stored bytes. byte_length counts those bytes.
Migration & auto-apply (AGENTS.md §2.7)
Three forward-only, idempotent embedded migrations under src/SbomService/__Libraries/StellaOps.SbomService.Persistence/Migrations/, picked up by the existing glob (Migrations\**\*.sql, excludes _archived) and auto-applied via AddStartupMigrations(...) — they converge on a fresh DB with no manual psql:
003_sbom_documents_sidecar.sql—CREATE TABLE IF NOT EXISTS sbom.sbom_documents.004_ledger_versions_tenant_id.sql—ALTER TABLE sbom.ledger_versions ADD COLUMN IF NOT EXISTS tenant_id TEXT+ the covering index.005_ledger_versions_tenant_chain.sql— replaces global(artifact_ref, sequence_number)uniqueness with tenant-local(artifact_ref, tenant_id, sequence_number)uniqueness for non-legacy rows.
The repository-level defensive DDL that once mirrored these migrations is retired (SPRINT_20260823_003 SBT-6); the embedded migrations are the only schema authority. The end-to-end tests boot the host on a fresh PostgreSQL container and the migration runner applies the full chain before any request.
Runbook — verify a subject’s SBOM round-trips
- Upload a CycloneDX SBOM:
curl -sS -X POST "$SBOM/api/v1/sbom/upload" \ -H "Authorization: Bearer $TENANT_A_TOKEN" -H "content-type: application/json" \ -d '{"artifactRef":"registry.io/app@sha256:...","sbom":{ "bomFormat":"CycloneDX","specVersion":"1.6", ... }}' - Fetch it back and compare bytes:
curl -sS "$SBOM/api/v1/sbom/subject/registry.io%2Fapp%40sha256:.../cyclonedx" \ -H "Authorization: Bearer $TENANT_A_TOKEN" -o roundtrip.json # roundtrip.json must be byte-for-byte identical to the uploaded `sbom` object's raw text - Negative checks:
- Unknown ref ⇒
404. - Same ref with a valid tenant B token ⇒
404(tenant isolation). GET /api/v1/scan/results?subject=unknownwith Scanner reachable ⇒404(no report; the bundle omits it).GET /api/v1/scan/results?subject=...with Scanner unavailable or rejected by outbound allowlists ⇒503problem JSON (truthful outage; never placeholder report JSON).
- Unknown ref ⇒
Audit-bundle consumer configuration (OfflineKit host)
The release bundle (devops/release/bundle/docker-compose.yml, service offlinekit-web) already sets ExportCenter__SbomAudit__BaseUrl and ExportCenter__VulnAudit__BaseUrl to http://sbomservice.stella-ops.local. The equivalent settings:
- SBOM source:
{ "ExportCenter": { "SbomAudit": { "BaseUrl": "http://sbomservice.stella-ops.local", "TimeoutSeconds": 30 } } } HttpSbomAuditSourceacquires a tenant-scoped client-credentials token withsbom:read, caches it by tenant and scope, and callsGET {BaseUrl}/api/v1/sbom/subject/{urlencoded subjectRef}/cyclonedxwithAuthorization: Bearer .... Token failure omits the section without sending a raw tenant header.- Configure
ExportCenter:VulnAudit:BaseUrlto the same SbomService base URL when vulnerability report sections should be included.HttpVulnAuditSourcereuses the tenant-scopedsbom:readbearer-token provider, callsGET {BaseUrl}/api/v1/scan/results?subject={urlencoded subjectRef}without raw tenant headers, includesreports/vuln-report.jsonon 200, and omits the entry on token failure or 404/null.
(Steps 1–3 are owned by the OfflineKit host, not SbomService. The token provider uses the Export:Nis2Soa:ServiceAccount credential and Export:Nis2Soa:AuthorityBaseUrl.)
Deployment
SbomService ships as service sbomservice in the release bundle. On startup AddStartupMigrations applies every pending embedded migration, including 003, 004 and 005, forward-only and idempotent; no manual psql is required. Pre-existing ledger rows keep tenant_id = NULL and return 404 to every tenant until re-uploaded.
To roll out a rebuilt image to a running estate, recreate the container with --force-recreate (a plain docker compose up -d <svc> can keep the old image), then run the round-trip runbook above: upload under a tenant, fetch the subject back byte-for-byte, and confirm the cross-tenant 404. Rolling out is an operator action that needs explicit authorization.
