Skip to content

feat(containers): generate a digest-bound SBOM for the two dashboard images (#3321) - #3377

Merged
Xore merged 1 commit into
mainfrom
oc/3321-digest-bound-sbom
Sep 27, 2026
Merged

Xore merged 1 commit into
mainfrom
oc/3321-digest-bound-sbom

Conversation

@Xore

@Xore Xore commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #3321.

What this does

The two dashboard container images (backend-service, dashboard-next) now get a
CycloneDX SBOM generated at build time, keyed to the exact image digest that was
just pushed, scanned with the same pinned Trivy the base-image scan already uses,
retained for 90 days as a workflow artifact, and mirrored to the homeserver at
/var/image-sbom/.

A digest, not a tag, is the join key everywhere. If a digest cannot be resolved,
the build fails — it never falls back to emitting an SBOM for whatever the tag
currently points at.

Why a script rather than buildx --sbom=true

Two reasons:

  1. It inventories the final built image — base plus everything COPY'd in —
    rather than the Dockerfile's declared inputs. For these two images the layer
    contents are most of what you would want inventoried.
  2. The same generator works for images that were not built in this workflow. The
    manual path is documented, so a rebuilt-on-the-homeserver image can be
    inventoried without a CI round-trip.

One stamp the upstream tools do not give you

syft's CycloneDX output records the component as name + tag and does not carry
the image digest. I verified this against syft 1.52.0 rather than assuming it, so
generate-image-sbom.sh stamps apiary:image-digest,
apiary:image-reference and apiary:image-name, sets component.version to the
digest, and then re-reads the file to confirm the stamp landed before
publishing. A silent stamp failure fails the step.

I also confirmed a real trivy sbom 0.74.0 parses the stamped document
unchanged, so the extra properties do not break the consumer.

Scope

Deliberately narrow:

  • Only the two dashboard rows carry sbom: true. Every added step is gated on
    matrix.sbom == true, so the other 16 matrix rows are unchanged in behaviour.
  • PR runs get no SBOM, and emit a ::notice saying so. A PR row has
    push: false and load: false, so there is no image and no digest to key to.
    Emitting a tag-keyed SBOM there would be the exact failure mode this issue is
    about.
  • CVE findings are report-only, mirroring the existing base-image scan. They
    annotate the SBOM, they do not fail the build. Generating the inventory is a
    hard failure; scanning it is not.
  • Provenance attestation is out of scope — the issue lists it as a follow-up.

The one real refactor

image-security-scan.yml had the Trivy version, asset name and sha256 pinned
inline. Those moved into scripts/install-trivy.sh, which both workflows now
call, because two independently-pinned Trivies will drift and a drift there would
quietly change what the CVE numbers mean. The pin itself is unchanged
(0.74.0, same checksum) — only its location moved. A test asserts the sha256
appears in that script and nowhere else.

Retention

90 days, which is the ceiling for a public repo. A higher value is rejected by
the upload rather than silently clamped, so the limit is visible in CI instead of
being a surprise.

prune-image-sbom.sh keeps the newest 10 SBOMs per image on the homeserver and
re-points latest.sbom.json at the newest survivor.

Verification

Tests:

  • scripts/tests/test_3321_image_sbom.py — 20 tests, functional, with stub
    syft/trivy binaries
  • tests/docs/test_3321_fix.py — 11 structural tests (4 need PyYAML; the other 7
    are dependency-free grep controls so the coverage does not silently vanish in
    a minimal environment)

Real-tool run, not just stubbed: syft 1.52.0 and Trivy 0.74.0 tarballs were
downloaded, sha256-verified, extracted, and the full generate → stamp → verify →
trivy sbom → report → publish → prune chain was executed against a real
digest-pinned alpine:3.20 reference. Both installers' download and idempotent
re-run paths were exercised.

Gates replicated locally and passing: actionlint 1.7.7 (SHELLCHECK_OPTS="-S warning"),
zizmor 1.30.1 (no blocking rules — all interpolations pass via env:),
bash -n and shellcheck --severity=error, hadolint 2.14.0, pytest tests/docs/
(444 passed, 1 xfailed), and the check-doc-paths-exist /
check-doc-stale-paths / check-docs-reachable / check-public-leaks scripts.

CI on this branch is green across the board: 107 passed, 0 failed.

Two things worth knowing

GitHub output keys use underscores. sbom_digest_hex, not sbom-digest-hex.
GitHub's expression grammar treats - as an operator, so a hyphenated key does
not read back cleanly. Caught before it shipped.

This branch is rebased onto current main. The first CI run failed
Dashboard-next browser matrix, which is unrelated to this change — this branch
touched no frontend files. main had meanwhile landed abc6a140 (#3331), which
moves the frontend jobs off a hardcoded node-version: "24" onto the node major
the image actually ships (22), and that commit records the browser matrix passing
58/58 on node 22. Rebasing picked up the fix and the job now passes. Worth
knowing in case the 24/22 split bites another branch that predates abc6a140.

Separately, scripts/tests/test_compose_drift_watch_sweep.py has two tests
(test_unreadable_compose_yml_manifest_entry_reaches_privileged_fallback and
test_env_locked_project_resolves_limits_through_privileged_helper) that fail in
my local environment on a clean tree — confirmed by stashing this branch and
re-running. They are environment-sensitive, they pass on the CI runner, and they
are not caused by this change.

Docs updated in docs/CI-CD.md (new section plus Checks bullets) and
docs/HOMESERVER-DISK-LAYOUT.md (/var/image-sbom/ and the CI-created-directories
table). The homeserver directory is provisioned by a new idempotent
provision-image-sbom step in install-homeserver.sh; if it is missing, publish
degrades to a warning rather than failing the build.

@github-actions

Copy link
Copy Markdown

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 1 package(s) with unknown licenses.
See the Details below.

License Issues

.github/workflows/containers.yml

PackageVersionLicenseIssue Type
actions/upload-artifact4.*.*NullUnknown License

OpenSSF Scorecard

PackageVersionScoreDetails
actions/actions/upload-artifact 4.*.* 🟢 5.2
Details
CheckScoreReason
Maintained⚠️ 00 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0
Binary-Artifacts🟢 10no binaries found in the repo
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Code-Review🟢 10all changesets reviewed
Packaging⚠️ -1packaging workflow not detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Pinned-Dependencies⚠️ 1dependency not pinned by hash detected -- score normalized to 1
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
SAST🟢 10SAST tool is run on all commits
Branch-Protection⚠️ 0branch protection not enabled on development/release branches

Scanned Files

  • .github/workflows/containers.yml

…images (#3321)

There was no inventory of what is inside apiary-backend or dashboard-next.
Every other image in the fleet was covered at its base by
image-security-scan.yml, but a built dashboard image is its base plus
everything COPYed in after it, and nothing recorded that difference -- so
answering "are we affected?" for a dashboard CVE meant rebuilding the image
or exec-ing into the running container.

containers.yml now emits a CycloneDX SBOM for backend-service and
dashboard-next at build time, keyed by the pushed manifest digest:

- an `sbom: true` matrix opt-in on those two rows, and every step gated on
  it, so the other sixteen rows build exactly as before;
- steps limited to events that push. A pull_request row builds with
  push=false and load=false, so it has no image and therefore no digest to
  key an inventory to -- and a tag-keyed inventory would describe whatever
  that tag pointed at when syft ran. Those rows emit a notice saying so
  rather than leaving the gap unexplained;
- scripts/generate-image-sbom.sh refuses an unpinned reference, and stamps
  the digest into the document as CycloneDX properties on
  metadata.component. syft's own cyclonedx-json output records the image's
  name and tag and no digest at all (verified against syft 1.52.0), so a
  bare syft file cannot be traced back to the image it describes. The stamp
  is re-read from disk afterwards rather than trusted from the writer's
  exit code;
- three copies from one generator, so the CI path and the homeserver path
  cannot disagree about what a file claims to be: a digest-named CI artifact
  (90d, if-no-files-found: error), /var/image-sbom/<image>/<hex>.sbom.json
  as the record, and latest.sbom.json as the pointer a person or Arcane
  reads. Published only when the row ran on the homeserver -- the
  GitHub-hosted fallback's /var dies with the job. prune-image-sbom.sh
  keeps the newest 10 per image and re-points latest afterwards, because a
  pointer naming a pruned digest is worse than no pointer;
- /var/image-sbom is provisioned by install-homeserver.sh's
  provision-image-sbom step, mirroring provision-buildx-cache: /var is
  root:root 0755 so the workflow's own mkdir gets EACCES, and a #1609
  rebuild would not reproduce a hand-made directory.

The scan reads the SBOM, not the image, so the scan and the retained
inventory describe one package set. scripts/scan-image-sbom.sh runs
`trivy sbom` with image-security-scan.yml's flags verbatim and, like it,
refuses to conflate "found CRITICAL/HIGH" with "could not read this" --
trivy exits non-zero for both, and the second is a coverage gap rather than
a vulnerability. Both now install trivy through scripts/install-trivy.sh:
a version + asset + sha256 triple copied into two YAML files is how the
same CVE ends up graded two different ways, so the pin moves out of
image-security-scan.yml into the script and a test asserts it appears there
and nowhere else.

Report-only for findings, matching the base-image scan it mirrors;
generating the inventory is a hard failure, because the required
Containers gate aggregates the build job and a silently absent SBOM is the
gap this closes. Provenance attestation stays out of scope, as the issue
scopes it.

Refs #3321
@Xore
Xore force-pushed the oc/3321-digest-bound-sbom branch from 31ad36b to 7b39956 Compare September 27, 2026 00:32
@Xore
Xore merged commit ecd8875 into main Sep 27, 2026
119 of 120 checks passed
@Xore
Xore deleted the oc/3321-digest-bound-sbom branch September 27, 2026 01:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

containers: generate a digest-bound SBOM for backend-service and dashboard-next builds

1 participant