feat(containers): generate a digest-bound SBOM for the two dashboard images (#3321) - #3377
Merged
Merged
Conversation
Dependency ReviewThe following issues were found:
License Issues.github/workflows/containers.yml
OpenSSF Scorecard
Scanned Files
|
…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
force-pushed
the
oc/3321-digest-bound-sbom
branch
from
September 27, 2026 00:32
31ad36b to
7b39956
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3321.
What this does
The two dashboard container images (
backend-service,dashboard-next) now get aCycloneDX 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=trueTwo reasons:
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.
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.shstampsapiary:image-digest,apiary:image-referenceandapiary:image-name, setscomponent.versionto thedigest, 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 sbom0.74.0 parses the stamped documentunchanged, so the extra properties do not break the consumer.
Scope
Deliberately narrow:
sbom: true. Every added step is gated onmatrix.sbom == true, so the other 16 matrix rows are unchanged in behaviour.::noticesaying so. A PR row haspush: falseandload: 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.
annotate the SBOM, they do not fail the build. Generating the inventory is a
hard failure; scanning it is not.
The one real refactor
image-security-scan.ymlhad the Trivy version, asset name and sha256 pinnedinline. Those moved into
scripts/install-trivy.sh, which both workflows nowcall, 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.shkeeps the newest 10 SBOMs per image on the homeserver andre-points
latest.sbom.jsonat the newest survivor.Verification
Tests:
scripts/tests/test_3321_image_sbom.py— 20 tests, functional, with stubsyft/trivy binaries
tests/docs/test_3321_fix.py— 11 structural tests (4 need PyYAML; the other 7are 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 realdigest-pinned
alpine:3.20reference. Both installers' download and idempotentre-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 -nandshellcheck --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-leaksscripts.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, notsbom-digest-hex.GitHub's expression grammar treats
-as an operator, so a hyphenated key doesnot read back cleanly. Caught before it shipped.
This branch is rebased onto current
main. The first CI run failedDashboard-next browser matrix, which is unrelated to this change — this branchtouched no frontend files.
mainhad meanwhile landedabc6a140(#3331), whichmoves the frontend jobs off a hardcoded
node-version: "24"onto the node majorthe 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.pyhas two tests(
test_unreadable_compose_yml_manifest_entry_reaches_privileged_fallbackandtest_env_locked_project_resolves_limits_through_privileged_helper) that fail inmy 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) anddocs/HOMESERVER-DISK-LAYOUT.md(/var/image-sbom/and the CI-created-directoriestable). The homeserver directory is provisioned by a new idempotent
provision-image-sbomstep ininstall-homeserver.sh; if it is missing, publishdegrades to a warning rather than failing the build.