Give the container a writable HOME, and pin what it fetches - #396
vpetersson-bot wants to merge 2 commits into
Conversation
Five fixes, all found while investigating whether the image could run
unprivileged. That question is an architecture decision and is not settled
here; these stand on their own.
A caller-supplied uid cannot verify anything
--------------------------------------------
Jenkins' Docker Pipeline plugin passes `-u <uid>:<gid>` by default, so its
users already run this image unprivileged -- and it does not work. A uid with
no passwd entry gets HOME=/, cosign cannot create its TUF trust root at
$HOME/.sigstore, and refusing an unverifiable binary is a hard stop by
design. Measured against the published image with `-u 1000:1000`:
Attestation verification failed for cdxgen-linux-amd64.tar.gz:
cosign exited 1: ... mkdir /.sigstore: permission denied
Every ecosystem that fetches a bundle fails that way -- Go, Rust, .NET, Dart,
JVM, npm, PHP. Only Python survives, because cyclonedx-py is in the venv and
needs no bundle. The error reads as a supply-chain problem rather than a
permissions one, which is why it went unnoticed; Jenkins sets no CI variable
we recognise, so it lands in telemetry as ci.platform "unknown".
HOME now points at a writable directory. World-writable rather than
group-writable because the uid arrives at run time and no narrower grant
covers it. Deliberately not `--gid 0`: measured against this image, primary
gid 0 grants access to three apt lock files and nothing else, and a caller
passing --user overrides it anyway.
CONAN_HOME moves under HOME for the same reason -- /root is mode 700, so the
profile detected at build time was unreadable to any other uid, and conan
creates .conan.db lazily so the directory has to be writable. Putting it in
/opt would have meant a second world-writable tree containing conan's
executable plugins.
Verified across every platform shape, on a lock file that forces a bundle
fetch and therefore cosign: GitLab, Bitbucket and GitHub unchanged; Jenkins
(-u 1000:1000) and an arbitrary -u 5000:5000 fixed. Attestations verify in
all five, and output is owned by the caller instead of by root.
The action pinned a tag, not an image
-------------------------------------
action.yml referenced `:latest`, which resolves when the step runs. A
consumer pinning `sbomify/sbomify-action@<sha>` -- which SECURITY.md tells
them to do -- pinned this file while the image underneath it floated. Now a
digest. `:latest` was also the wrong tag to track: it is published before the
versioned tag, so it lags the release it represents.
A missing daemon is not a missing image
---------------------------------------
syft falls back to the registry when it cannot reach the socket, so a
permissions problem on /var/run/docker.sock surfaces as "UNAUTHORIZED:
authentication required" and sends the user to check registry credentials.
Detected explicitly now, ahead of the not-found check, and reported with both
remedies.
Only syft speaks the scheme syntax
----------------------------------
`docker-archive:/tmp/image.tar` scans an image with no daemon at all, and
DOCKER_IMAGE is passed through verbatim, so it already works. But trivy takes
an archive through `--input <path>` and cdxgen through a bare path: handed a
prefixed value each looks for an image literally named
"docker-archive:/tmp/image.tar". Both decline now, so syft takes it. Matched
against a fixed scheme list rather than splitting on the first colon, which
would break `localhost:5000/app`.
tools.toml documented a guarantee that does not exist
-----------------------------------------------------
The comment said freeze_tool_versions.py stamps a release tag over
"tools-rolling" at build time, "so a cut release keeps fetching the bundles
it was tested against". It does not: that script only rewrites `version_from`
lines, and sbom-tools publishes exactly one tag. A cut release fetches
whatever master's bundles are on the day it runs, so two runs of the same
pinned version can resolve different toolchains. Authenticity still holds --
every bundle is cosign-verified -- but the version floats. Corrected to
describe what happens; closing it needs sbom-tools to cut immutable tags
first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves reliability and supply-chain determinism for the container action when run under caller-supplied UIDs, and tightens SBOM image scanning behavior by (a) correctly handling syft’s scheme-prefixed image refs and (b) distinguishing “Docker daemon unreachable” from “image not found.” It also hard-pins the action’s container image by digest to match the security guidance around pinning.
Changes:
- Make the container’s
HOMEstable and writable for arbitrary--user <uid>:<gid>runs; move Conan state under$HOMEaccordingly. - Add scheme detection for syft-style image references and route scheme-prefixed refs away from trivy/cdxgen so syft can handle them; add explicit detection for unreachable Docker daemons.
- Pin
action.yml’s container image to an immutable digest; clarifytools.tomlcomments about tool bundle version floating; add tests for the new image-ref/daemon-detection behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
tests/test_docker_image_refs.py |
Adds tests for syft scheme parsing, generator routing, and daemon-unreachable detection. |
sbomify_action/tools.toml |
Corrects documentation about tool-bundle pinning/immutability guarantees. |
sbomify_action/_generation/utils.py |
Introduces scheme parsing + daemon-unreachable detection; adjusts error logging flow. |
sbomify_action/_generation/generators/trivy.py |
Declines scheme-prefixed image refs so syft can handle them. |
sbomify_action/_generation/generators/cdxgen.py |
Declines scheme-prefixed image refs so syft can handle them. |
Dockerfile |
Creates a stable writable HOME for arbitrary UIDs; relocates Conan home and permissions. |
action.yml |
Pins the action’s image by digest (with version tag for readability). |
Suppressed comments (1)
sbomify_action/_generation/utils.py:666
- When the Docker daemon is unreachable, the code still proceeds to the "image not found" check and can raise DockerImageNotFoundError (e.g. via the "pull access denied" pattern) even though the warning above says registry/auth errors are symptomatic of the missing socket. This can misclassify the failure and send users to the wrong remediation; gate the not-found classification on the daemon being reachable.
# Check if this is a Docker image not found error (user configuration issue)
# Log at WARNING level since this isn't a bug - user specified a non-existent image
if docker_image and detect_docker_image_not_found(stderr):
logger.warning(f"Docker image '{docker_image}' not found")
log_command_error(command_name, stderr, stdout, level="warning")
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # A scheme-prefixed reference is syft's syntax. cdxgen reads an archive | ||
| # from a bare path with `-t docker`, so a prefixed value would be | ||
| # treated as an image name and fail to resolve. Decline, and let the | ||
| # syft generator take it. |
sbom-tools now cuts release tags, so this can stop tracking master. "tools-rolling" is replaced in place on every push, so a cut sbomify-action release fetched whatever master's bundles happened to be on the day it ran. Two runs of the same pinned version, weeks apart, could resolve different toolchains and produce different SBOMs. Authenticity was never in doubt -- every bundle is cosign-verified against its Sigstore attestation -- but the version floated, which is not what pinning a release should mean. tools-26.09.0 is the first tag sbom-tools has ever published, so the `refs/tags/.+` half of the certificate identity regex had never been exercised; only `refs/heads/master` had, via rolling. Checked before pinning anything to it: 32 assets present (8 bundles x 2 arches x 2 files), and cdxgen-linux-amd64.tar.gz verifies against a certificate carrying refs/tags/tools-26.09.0. Fetching through the pin resolves end to end -- "Fetching the cdxgen bundle (tools-26.09.0, amd64)" -- with both attestations verified. The trade is deliberate and worth stating: master no longer exercises a freshly bumped tool before a release is cut, which is what rolling existed for, and there is no environment override. Testing a bump now means pointing tools_release at "tools-rolling" temporarily. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tests/test_docker_image_refs.py:75
test_declines_scheme_prefixed_referencecan pass without exercising the new scheme-decline logic:TrivyImageGenerator.supports()returns early when_TRIVY_AVAILABLEis false, so the assertion is satisfied even if the scheme check were removed. Consider forcing the generator past its tool-availability gate viamonkeypatchso the test reliably verifies the new behavior across environments.
@pytest.mark.parametrize("generator", [TrivyImageGenerator(), CdxgenImageGenerator()])
def test_declines_scheme_prefixed_reference(self, generator):
input = GenerationInput(
docker_image="docker-archive:/tmp/image.tar",
output_file="out.json",
output_format="cyclonedx",
)
assert generator.supports(input) is False
| if docker_image and detect_docker_daemon_unreachable(combined_output(stderr, stdout)): | ||
| logger.warning( | ||
| f"Could not reach the Docker daemon while scanning '{docker_image}'. " | ||
| "The image was then looked for in a registry, so any authentication " | ||
| "error above is a symptom rather than the cause. Either give the " |
Five fixes found while investigating whether the image could run unprivileged. That question is not settled here — it turned out to be an architecture decision (container action vs composite), and #394 was closed because GitHub's docs forbid
USERin a Docker container action. These stand on their own.1. A caller-supplied uid cannot verify anything — Jenkins is broken today
Jenkins' Docker Pipeline plugin passes
-u <uid>:<gid>by default, so its users already run this image unprivileged. It does not work. A uid with no passwd entry getsHOME=/, cosign cannot create its TUF trust root at$HOME/.sigstore, and refusing an unverifiable binary is a hard stop by design:Every bundle-fetching ecosystem fails this way — Go, Rust, .NET, Dart, JVM, npm, PHP. Only Python survives, because
cyclonedx-pyis in the venv and needs no bundle. Two reasons this went unnoticed: the error reads as a supply-chain problem rather than a permissions one, and Jenkins sets no CI variable we recognise, so it lands in telemetry asci.platform: unknown.Verified across every platform shape, using a lock file that forces a bundle fetch and therefore cosign — my first attempt used
requirements.txt, which never reaches cosign at all and gave four meaningless green rows:/builds/…, root)/opt/atlassian/…, root)/github/workspace, 1001)-u 1000:1000)1000:1000-u 5000:5000)5000:5000Attestations verify in all five, and output is owned by the caller instead of by root.
Notes on the shape of the fix:
--gid 0. Measured against this image, primary gid 0 grants access to exactly three apt/dpkg lock files and nothing else, and--useroverrides it anyway. The Dockerfile records that measurement so it doesn't get re-added on instinct.CONAN_HOMEmoves under$HOME—/rootis mode 700 so the build-time profile was unreadable to any other uid, and conan creates.conan.dblazily so the directory must be writable. Putting it in/optwould have meant a second world-writable tree containing conan's executable plugins.USERline. The image still runs as root by default, as GitHub requires. This only fixes callers who already supply a uid — who are broken today and cannot get worse.2. The action pinned a tag, not an image
action.ymlreferenced:latest, resolved when the step runs. A consumer pinningsbomify/sbomify-action@<sha>— whichSECURITY.mdinstructs — pinned this file while the image underneath floated. Now a digest.:latestwas also the wrong tag to track: it is published before the versioned tag, so it lags the release it represents (:latest→26.8.0+ed3f547,:26.8.0→ a build five minutes later).action.ymla required release step, and@masterconsumers stop auto-tracking new images. That matches the intent of the old# Bump/pin this to releasecomment, but there's no tooling doing it today. Say if you'd rather only tags carry the pin.3. A missing daemon is not a missing image
syft falls back to the registry when it cannot reach the socket, so a permissions problem on
/var/run/docker.socksurfaces asUNAUTHORIZED: authentication required— sending the user to check registry credentials. Worse, that's the same shape as the existing private-registry issues in Sentry, so it gets triaged as one. Detected explicitly now, ahead of the not-found check, and reported with both remedies.4. Only syft speaks the scheme syntax
DOCKER_IMAGE: docker-archive:/tmp/image.tarscans an image with no daemon at all, and the value is passed through verbatim, so this already works today. But trivy takes an archive through--input <path>and cdxgen through a bare path — handed a prefixed value, each looks for an image literally nameddocker-archive:/tmp/image.tar. Both decline now so syft takes it.Matched against a fixed scheme list rather than splitting on the first colon, which would break
localhost:5000/app.5. The toolchain is pinned to an immutable release
The comment claimed
freeze_tool_versions.pystamps a release tag overtools-rollingat build time, "so a cut release keeps fetching the bundles it was tested against". It did not — that script only rewritesversion_fromlines, and sbom-tools published exactly one tag. So a cut release fetched whatever master's bundles were on the day it ran, and two runs of the same pinned version could resolve different toolchains. Authenticity always held (every bundle is cosign-verified); the version floated.sbom-tools has now cut
tools-26.09.0, so this pins to it.That tag is the first release the repo has ever published, which means the
refs/tags/.+half of the cosign certificate-identity regex had never been exercised — onlyrefs/heads/masterhad, via rolling. If that half were wrong, every consumer on the new tag would fail closed at attestation, with the "Refusing to use a binary…" error that reads as a supply-chain compromise. Checked before pinning:cdxgen-linux-amd64.tar.gz→Verified OK, certificate carryingrefs/tags/tools-26.09.0Fetching the cdxgen bundle (tools-26.09.0, amd64), both attestations verifiedtools-rollingexisted for — and there's no environment override (tool_manifest.py:490reads the TOML only). Testing a bump means pointingtools_releaseat"tools-rolling"temporarily. The alternative is to keep rolling in the file and havefreeze_tool_versions.pystamp the tag at image build time — the design the file was originally written for. Happy to build that instead.This floating toolchain is also a plausible explanation for the cdxgen secure-mode failures appearing on a specific date with no corresponding code change.
Checks
ruff check,ruff format --check,mypy(149 files),pytest— 3511 passed, 4 skipped, coverage 80.19%. 28 new tests covering scheme detection, generator routing, and daemon-vs-not-found.Follow-ups deliberately not in scope
USERin container actions; the viable routes are a composite action or leaving the daemon behind. Needs a decision.--source-nameon the image path. Scanningdocker-archive:image.tarproduces an SBOM namedimage.tar—syft.py:261passes--source-namefor lock files but the image branch omits it. The fix needs an interface decision (derive from the archive'sRepoTags? a new input?), so it deserves its own issue.docker-archive:is single-platform only. buildx's docker exporter refuses manifest lists and syft refuses buildx's OCI multi-arch. Multi-arch daemonless scanning needs a local registry.🤖 Generated with Claude Code