Skip to content

Give the container a writable HOME, and pin what it fetches - #396

Open
vpetersson-bot wants to merge 2 commits into
sbomify:masterfrom
vpetersson-bot:fix/pin-image-and-toolchain
Open

vpetersson-bot wants to merge 2 commits into
sbomify:masterfrom
vpetersson-bot:fix/pin-image-and-toolchain

Conversation

@vpetersson-bot

@vpetersson-bot vpetersson-bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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 USER in 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 gets HOME=/, cosign cannot create its TUF trust root at $HOME/.sigstore, and refusing an unverifiable binary is a hard stop by design:

Attestation verification failed for cdxgen-linux-amd64.tar.gz:
  cosign exited 1: ... mkdir /.sigstore: permission denied

Every bundle-fetching ecosystem fails this way — Go, Rust, .NET, Dart, JVM, npm, PHP. Only Python survives, because cyclonedx-py is 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 as ci.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:

shape before after
GitLab (/builds/…, root) ok ok
Bitbucket (/opt/atlassian/…, root) ok ok
GitHub (/github/workspace, 1001) ok ok
Jenkins (-u 1000:1000) FAIL ok, output owned 1000:1000
Arbitrary (-u 5000:5000) FAIL ok, output owned 5000:5000

Attestations verify in all five, and output is owned by the caller instead of by root.

Notes on the shape of the fix:

  • World-writable, not group-writable — the uid arrives at run time, so no narrower grant covers it.
  • Deliberately not --gid 0. Measured against this image, primary gid 0 grants access to exactly three apt/dpkg lock files and nothing else, and --user overrides it anyway. The Dockerfile records that measurement so it doesn't get re-added on instinct.
  • CONAN_HOME moves under $HOME/root is mode 700 so the build-time profile was unreadable to any other uid, and conan creates .conan.db lazily so the directory must be writable. Putting it in /opt would have meant a second world-writable tree containing conan's executable plugins.
  • No USER line. 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.yml referenced :latest, resolved when the step runs. A consumer pinning sbomify/sbomify-action@<sha> — which SECURITY.md instructs — pinned this file while the image underneath 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 (:latest26.8.0+ed3f547, :26.8.0 → a build five minutes later).

⚠️ This makes bumping action.yml a required release step, and @master consumers stop auto-tracking new images. That matches the intent of the old # Bump/pin this to release comment, 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.sock surfaces as UNAUTHORIZED: 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.tar scans 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 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.

5. The toolchain is pinned to an immutable release

The comment claimed 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 did not — that script only rewrites version_from lines, 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 — only refs/heads/master had, 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:

  • 32 assets — 8 bundles × 2 arches × 2 files
  • cdxgen-linux-amd64.tar.gzVerified OK, certificate carrying refs/tags/tools-26.09.0
  • End-to-end fetch through the pin: Fetching the cdxgen bundle (tools-26.09.0, amd64), both attestations verified

⚠️ Deliberate trade: master no longer exercises a freshly bumped tool before a release is cut, which is what tools-rolling existed for — and there's no environment override (tool_manifest.py:490 reads the TOML only). Testing a bump means pointing tools_release at "tools-rolling" temporarily. The alternative is to keep rolling in the file and have freeze_tool_versions.py stamp 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), pytest3511 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

  • The non-root question itself. GitHub's docs forbid USER in container actions; the viable routes are a composite action or leaving the daemon behind. Needs a decision.
  • --source-name on the image path. Scanning docker-archive:image.tar produces an SBOM named image.tarsyft.py:261 passes --source-name for lock files but the image branch omits it. The fix needs an interface decision (derive from the archive's RepoTags? 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

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>
Copilot AI lite review requested due to automatic review settings August 29, 2026 12:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 HOME stable and writable for arbitrary --user <uid>:<gid> runs; move Conan state under $HOME accordingly.
  • 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; clarify tools.toml comments 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.

Comment on lines +397 to +400
# 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>
Copilot AI review requested due to automatic review settings August 29, 2026 12:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_reference can pass without exercising the new scheme-decline logic: TrivyImageGenerator.supports() returns early when _TRIVY_AVAILABLE is false, so the assertion is satisfied even if the scheme check were removed. Consider forcing the generator past its tool-availability gate via monkeypatch so 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

Comment on lines +652 to +656
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 "
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.

2 participants