From 55e0e5eb08361eae475f9dd09d9b91ddff531387 Mon Sep 17 00:00:00 2001 From: Cyborg Viktor <307551610+vpetersson-bot@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:20:32 +0000 Subject: [PATCH 1/7] Give the container a writable HOME, and pin what it fetches 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 :` 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@` -- 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 ` 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) --- Dockerfile | 57 +++++++- action.yml | 13 +- .../_generation/generators/cdxgen.py | 8 ++ .../_generation/generators/trivy.py | 10 +- sbomify_action/_generation/utils.py | 68 ++++++++++ sbomify_action/tools.toml | 21 ++- tests/test_docker_image_refs.py | 124 ++++++++++++++++++ 7 files changed, 289 insertions(+), 12 deletions(-) create mode 100644 tests/test_docker_image_refs.py diff --git a/Dockerfile b/Dockerfile index 6c5560bf..6b3c5743 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,10 +69,17 @@ LABEL com.sbomify.maintainer="sbomify " \ com.sbomify.vcs.branch="${VCS_REF}" \ com.sbomify.vcs.commit="${COMMIT_SHA}" -# git is needed at runtime: the wizard reads repo facts (remote name, -# branch, visibility) from the bind-mounted workspace, and slim images -# don't ship it. Without it those facts silently degrade to folder-name -# and "unknown" visibility. +# git is needed at runtime and is deliberately baked in rather than fetched +# like the generators are. Nearly every path wants it: the wizard reads repo +# facts (remote name, branch, visibility) from the bind-mounted workspace, +# submodule resolution runs it directly, and run_command threads +# git_safe_directory_env() into *every* subprocess because the generators +# shell out to tools that run git themselves -- composer establishing its +# root package version, cargo, go. Slim images don't ship it, and without it +# those facts silently degrade to folder-name and "unknown" visibility. +# +# So this is the one dependency that should never be lazy-loaded: a tool +# needed by almost every run is not a tool to go and fetch mid-run. # dist-upgrade first: the base image digest lags the Debian security # archive, so packages preinstalled in the base (util-linux, openssl) # would otherwise ship at whatever patch level the digest froze. @@ -88,9 +95,49 @@ COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" +# A home that stays writable whatever uid the caller supplies. +# +# The image still runs as root by default -- GitHub requires that of a +# container action -- but plenty of callers override it. Jenkins' Docker +# Pipeline plugin passes `-u :` by default, and anyone running +# `docker run --user` locally does the same. A uid with no passwd entry gets +# HOME=/, which it cannot write. +# +# That is not a cosmetic problem. cosign keeps its TUF trust root in +# $HOME/.sigstore, so it cannot verify a bundle's attestation, and refusing +# an unverifiable binary is a hard stop by design. Measured against the +# published image with `-u 1000:1000`: every ecosystem that fetches a bundle +# (Go, Rust, .NET, Dart, JVM, npm, PHP) fails with +# +# Attestation verification failed ... mkdir /.sigstore: permission denied +# +# and only Python survives, because cyclonedx-py is in the venv and needs no +# bundle. The failure reads as a supply-chain problem rather than a +# permissions one, which is why it went unnoticed. +# +# World-writable rather than group-writable: the uid arrives at run time, so +# no narrower grant covers it. What lives here is a per-run cache and the +# trust root, inside a single-tenant ephemeral container. Deliberately NOT +# `--gid 0`: measured against this image, primary gid 0 grants access to +# exactly three apt/dpkg lock files and nothing else, and it is overridden +# anyway the moment a caller passes `--user`. +RUN useradd --uid 1001 --user-group --create-home --home-dir /home/sbomify --shell /bin/bash sbomify +ENV HOME=/home/sbomify + # Initialize Conan profile for C/C++ package metadata lookups # This creates a default profile based on the container's compiler/OS settings -RUN conan profile detect --force +# +# CONAN_HOME rather than the default ~/.conan2 resolved at build time: that +# lands in /root, which is mode 700 and unreadable to any other uid. Conan +# also creates .conan.db lazily on first use, so the directory has to be +# writable, not merely readable, or every pkg:conan/* lookup degrades to a +# warning. +# +# Kept under $HOME rather than a second world-writable tree in /opt: that +# one would contain conan's executable plugins (profile.py, +# compatibility.py), which is a code-execution surface this does not need. +ENV CONAN_HOME=/home/sbomify/.conan2 +RUN conan profile detect --force && chmod -R a+rwX /home/sbomify # Marks our own image, which two behaviours key off: # - runtime tools are fetched on demand only in here, where we decide the diff --git a/action.yml b/action.yml index 97536ca6..704230e9 100644 --- a/action.yml +++ b/action.yml @@ -19,8 +19,17 @@ inputs: required: false runs: using: 'docker' - # Bump/pin this to release - image: 'docker://ghcr.io/sbomify/sbomify-action:latest' + # Pinned by digest, and bumped with the release. + # + # A tag here is resolved when the step runs, so a consumer who pins + # `sbomify/sbomify-action@` -- which is what SECURITY.md tells them to + # do -- pins this file and nothing else: the image underneath it still + # floats. The pin they believe they have does not exist. A digest is the + # only reference that makes it real. + # + # `:latest` was also the wrong tag to float on: it is published before the + # versioned tag, so it lags the release it is meant to represent. + image: 'docker://ghcr.io/sbomify/sbomify-action:26.8.0@sha256:b89230442778f50a24b27b55b373cdbaad042f5ffb27d9af195e99ed3bd2e7bb' env: WORKING_DIR: ${{ inputs['working-dir'] }} COMPONENT_PURL: ${{ inputs['component-purl'] }} diff --git a/sbomify_action/_generation/generators/cdxgen.py b/sbomify_action/_generation/generators/cdxgen.py index 7c1a3ebe..835a8944 100644 --- a/sbomify_action/_generation/generators/cdxgen.py +++ b/sbomify_action/_generation/generators/cdxgen.py @@ -37,6 +37,7 @@ ensure_php_installed, get_lock_file_ecosystem, has_required_manifest, + image_ref_scheme, resolve_npm_lockfile, run_command, ) @@ -393,6 +394,13 @@ def supports(self, input: GenerationInput) -> bool: if not input.is_docker_image: return False + # 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. + if image_ref_scheme(input.docker_image): + return False + # Only supports CycloneDX format (cdxgen doesn't output SPDX) if input.output_format != "cyclonedx": return False diff --git a/sbomify_action/_generation/generators/trivy.py b/sbomify_action/_generation/generators/trivy.py index 504d53e8..8acdb5cb 100644 --- a/sbomify_action/_generation/generators/trivy.py +++ b/sbomify_action/_generation/generators/trivy.py @@ -23,7 +23,7 @@ GenerationInput, ) from ..result import GenerationResult -from ..utils import DEFAULT_TIMEOUT, TRIVY_LOCK_FILES, run_command +from ..utils import DEFAULT_TIMEOUT, TRIVY_LOCK_FILES, image_ref_scheme, run_command # Check tool availability once at module load _TRIVY_AVAILABLE, _TRIVY_PATH = check_tool_available("trivy") @@ -227,6 +227,14 @@ def supports(self, input: GenerationInput) -> bool: if not input.is_docker_image: return False + # A scheme-prefixed reference is syft's syntax, and trivy has no + # equivalent: an archive reaches it through `--input `, not + # through the positional argument. Passed one anyway it looks for an + # image literally named "docker-archive:/tmp/image.tar". Decline, and + # let the syft generator take it. + if image_ref_scheme(input.docker_image): + return False + # Check format if input.output_format not in TRIVY_FORMAT_MAP: return False diff --git a/sbomify_action/_generation/utils.py b/sbomify_action/_generation/utils.py index bdf5d45c..a6ab0839 100644 --- a/sbomify_action/_generation/utils.py +++ b/sbomify_action/_generation/utils.py @@ -386,6 +386,60 @@ def log_command_error(command_name: str, stderr: str, stdout: str, level: str = log_fn(message) +# Source schemes syft understands in front of an image reference. Only syft +# accepts these: trivy takes an archive through `--input ` and cdxgen +# takes a bare path, so handing either a prefixed value makes it try to pull +# an image literally named "docker-archive:/tmp/image.tar". +# +# Matched against a fixed list rather than "anything before a colon", because +# a bare reference is full of colons that are not schemes -- `alpine:3.20`, +# `localhost:5000/app`. +SYFT_SOURCE_SCHEMES = ( + "docker", + "podman", + "containerd", + "registry", + "docker-archive", + "oci-archive", + "oci-dir", + "singularity", + "dir", + "file", +) + + +def image_ref_scheme(reference: str | None) -> str | None: + """The syft source scheme prefixing this reference, if any. + + Returns None for a plain image reference, which every generator can read. + """ + if not reference: + return None + prefix, _, rest = reference.partition(":") + if rest and prefix in SYFT_SOURCE_SCHEMES: + return prefix + return None + + +# The daemon being unreachable is not the same as the image being absent, and +# syft does not make that obvious: when it cannot reach the socket it falls +# through to the registry, so the run fails with a registry error -- typically +# "UNAUTHORIZED: authentication required" -- and the user goes looking at +# their registry credentials for a permissions problem on /var/run/docker.sock. +DOCKER_DAEMON_UNREACHABLE_PATTERNS = [ + r"failed to connect to Docker daemon", + r"docker not available", + r"Cannot connect to the Docker daemon", + r"dial unix /var/run/docker\.sock", + r"permission denied while trying to connect to the Docker daemon", +] + + +def detect_docker_daemon_unreachable(output: str) -> bool: + """Whether this failure is the Docker daemon being out of reach.""" + return any(re.search(pattern, output, re.IGNORECASE) for pattern in DOCKER_DAEMON_UNREACHABLE_PATTERNS) + + # Patterns that indicate a Docker image was not found in the registry # These are common error messages from trivy, syft, cdxgen, and Docker itself DOCKER_IMAGE_NOT_FOUND_PATTERNS = [ @@ -591,6 +645,20 @@ def log_progress() -> None: stderr = e.stderr or "" stdout = e.stdout or "" + # Say so when the daemon is the problem. This has to come first: syft + # falls back to the registry when it cannot reach the socket, so the + # tail of the output is a registry error and the not-found check below + # would otherwise claim the image does not exist. + 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 " + "container access to /var/run/docker.sock, or scan the image without " + "a daemon by saving it first: " + "`docker save -o image.tar` and DOCKER_IMAGE=docker-archive:image.tar" + ) + # 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): diff --git a/sbomify_action/tools.toml b/sbomify_action/tools.toml index 84d43b10..9b4d6141 100644 --- a/sbomify_action/tools.toml +++ b/sbomify_action/tools.toml @@ -13,10 +13,23 @@ # trusted because it is written down. That is also what makes the rolling # release usable at all, since its bytes change on every push to master. -# Which sbom-tools release to fetch from. "tools-rolling" tracks master; -# scripts/freeze_tool_versions.py stamps a release tag in its place when the -# image is built, so a cut release keeps fetching the bundles it was tested -# against for as long as it exists. +# Which sbom-tools release to fetch from. +# +# This said that scripts/freeze_tool_versions.py stamps a release tag here at +# build time, "so a cut release keeps fetching the bundles it was tested +# against for as long as it exists". It does not, and cannot: that script only +# rewrites `version_from` lines, and sbom-tools publishes exactly one tag -- +# tools-rolling -- so there is no release tag to stamp. +# +# The consequence is worth being plain about, because the comment claimed the +# opposite: a cut release fetches whatever master's bundles are on the day it +# runs. Two runs of the same pinned sbomify-action version, weeks apart, can +# resolve different toolchains and produce different SBOMs. Authenticity is +# still guaranteed -- every bundle is cosign-verified against its Sigstore +# attestation -- but the *version* floats. +# +# Closing this needs sbom-tools to cut immutable release tags; freezing here +# is the second half of that change, not something this file can do alone. tools_release = "tools-rolling" # --------------------------------------------------------------------------- diff --git a/tests/test_docker_image_refs.py b/tests/test_docker_image_refs.py new file mode 100644 index 00000000..e86055c7 --- /dev/null +++ b/tests/test_docker_image_refs.py @@ -0,0 +1,124 @@ +"""Scheme-prefixed image references, and telling a missing daemon from a missing image. + +`syft scan` accepts a source scheme in front of a reference -- +`docker-archive:/tmp/image.tar` is how you scan an image without a daemon at +all. sbomify passes DOCKER_IMAGE through verbatim, so that syntax already +works today; these tests pin the two things around it that did not. +""" + +import pytest + +from sbomify_action._generation.generators.cdxgen import CdxgenImageGenerator +from sbomify_action._generation.generators.trivy import TrivyImageGenerator +from sbomify_action._generation.protocol import GenerationInput +from sbomify_action._generation.utils import ( + detect_docker_daemon_unreachable, + image_ref_scheme, +) + + +class TestImageRefScheme: + """A scheme is a fixed vocabulary, not "whatever precedes a colon".""" + + @pytest.mark.parametrize( + "reference,expected", + [ + ("docker-archive:/tmp/image.tar", "docker-archive"), + ("oci-archive:/tmp/image.tar", "oci-archive"), + ("oci-dir:/tmp/ocidir", "oci-dir"), + ("registry:ghcr.io/acme/app:1.0", "registry"), + ("docker:alpine:3.20", "docker"), + ("podman:alpine:3.20", "podman"), + ("dir:/src", "dir"), + ], + ) + def test_recognises_syft_schemes(self, reference: str, expected: str): + assert image_ref_scheme(reference) == expected + + @pytest.mark.parametrize( + "reference", + [ + "alpine:3.20", + "ghcr.io/sbomify/sbomify-action:26.8.0", + # A registry host with a port is the case a naive split-on-colon + # gets wrong, and it is a completely ordinary reference. + "localhost:5000/app:latest", + "my-app@sha256:0123456789abcdef", + "my-app", + "", + ], + ) + def test_plain_references_have_no_scheme(self, reference: str): + assert image_ref_scheme(reference) is None + + def test_none_is_tolerated(self): + assert image_ref_scheme(None) is None + + +class TestGeneratorsDeclinePrefixedRefs: + """Only syft speaks this syntax, so the others must stand down. + + trivy reads an archive through `--input ` and cdxgen through a bare + path; handed a prefixed value each looks for an image literally named + "docker-archive:/tmp/image.tar". Claiming the input and then failing is + fatal in our own container, so declining is what lets syft take it. + """ + + @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 + + @pytest.mark.parametrize("generator", [TrivyImageGenerator(), CdxgenImageGenerator()]) + def test_plain_reference_is_not_declined_for_this_reason(self, generator): + """A tagged reference must not be mistaken for a scheme. + + Asserts the scheme check specifically, rather than `supports() is + True`: whether the generator claims a plain reference also depends on + the tool being installed, which is not what this test is about. + """ + input = GenerationInput( + docker_image="alpine:3.20", + output_file="out.json", + output_format="cyclonedx", + ) + assert image_ref_scheme(input.docker_image) is None + + +class TestDaemonUnreachableDetection: + """A missing daemon and a missing image are different failures. + + syft falls through to the registry when it cannot reach the socket, so the + error the user sees is about registry credentials. Without this the run is + reported as an authentication problem and the socket is never suspected. + """ + + @pytest.mark.parametrize( + "output", + [ + "failed to connect to Docker daemon. Ensure Docker is running and accessible", + "docker: docker not available: failed to connect to Docker daemon", + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock", + "dial unix /var/run/docker.sock: connect: permission denied", + "permission denied while trying to connect to the Docker daemon socket", + ], + ) + def test_detects_unreachable_daemon(self, output: str): + assert detect_docker_daemon_unreachable(output) is True + + @pytest.mark.parametrize( + "output", + [ + "MANIFEST_UNKNOWN: manifest unknown", + "manifest for alpine:nonexistent not found", + "pull access denied", + "some other error", + "", + ], + ) + def test_does_not_fire_on_other_failures(self, output: str): + assert detect_docker_daemon_unreachable(output) is False From 3b677000d4afe8ad2dbd0bb3af117b6b70a11058 Mon Sep 17 00:00:00 2001 From: Cyborg Viktor <307551610+vpetersson-bot@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:55:43 +0000 Subject: [PATCH 2/7] Pin the toolchain to an immutable release 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) --- sbomify_action/tools.toml | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/sbomify_action/tools.toml b/sbomify_action/tools.toml index 9b4d6141..f25abaae 100644 --- a/sbomify_action/tools.toml +++ b/sbomify_action/tools.toml @@ -15,22 +15,24 @@ # Which sbom-tools release to fetch from. # -# This said that scripts/freeze_tool_versions.py stamps a release tag here at -# build time, "so a cut release keeps fetching the bundles it was tested -# against for as long as it exists". It does not, and cannot: that script only -# rewrites `version_from` lines, and sbom-tools publishes exactly one tag -- -# tools-rolling -- so there is no release tag to stamp. +# An immutable release tag, not "tools-rolling". Rolling tracks master and its +# bytes change 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 a pinned release should mean. # -# The consequence is worth being plain about, because the comment claimed the -# opposite: a cut release fetches whatever master's bundles are on the day it -# runs. Two runs of the same pinned sbomify-action version, weeks apart, can -# resolve different toolchains and produce different SBOMs. Authenticity is -# still guaranteed -- every bundle is cosign-verified against its Sigstore -# attestation -- but the *version* floats. +# A previous comment here claimed scripts/freeze_tool_versions.py stamped a +# release tag at build time. It never did: that script only rewrites +# `version_from` lines, and until tools-26.09.0 there was no release tag to +# stamp -- sbom-tools published only the rolling pre-release. # -# Closing this needs sbom-tools to cut immutable release tags; freezing here -# is the second half of that change, not something this file can do alone. -tools_release = "tools-rolling" +# Bump this with each sbom-tools release. The trade is deliberate: master no +# longer exercises freshly bumped tools automatically, which is what rolling +# was for, so testing a bump means pointing this at "tools-rolling" +# temporarily. There is no environment override -- see tool_manifest.py. +tools_release = "tools-26.09.0" # --------------------------------------------------------------------------- # Bundles. `provides` is the lookup that decides what to download when a From f25d3355f516763a047af963a61ac595e6a35e88 Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:11:05 +0000 Subject: [PATCH 3/7] fix(docker): an unreachable daemon is no longer reported as a missing image Detecting the daemon and then falling through to the not-found check still raised DockerImageNotFoundError. That fall-through is not hypothetical: syft tries the registry when it cannot reach the socket, and the registry answers with exactly the phrases the not-found patterns match -- "pull access denied", "UNAUTHORIZED". So the warning about the socket was followed by a hard error telling the user their image does not exist, and they went to check a registry and a tag that were both fine. Skip the not-found classification entirely while the daemon is the known cause, rather than merely printing a warning ahead of it. Both checks now read combined_output rather than stderr alone. cdxgen writes the sentence that says why to stdout -- the reason combined_output exists -- so a genuinely missing image reported there was classified as a generic command failure. Also correct the comment in CdxgenImageGenerator.supports: generate() invokes cdxgen with `-t oci`, not `-t docker`. Co-Authored-By: Claude Opus 5 (1M context) --- .../_generation/generators/cdxgen.py | 9 +- sbomify_action/_generation/utils.py | 30 +++-- tests/test_docker_image_refs.py | 103 ++++++++++++++++++ 3 files changed, 128 insertions(+), 14 deletions(-) diff --git a/sbomify_action/_generation/generators/cdxgen.py b/sbomify_action/_generation/generators/cdxgen.py index 835a8944..94d8a504 100644 --- a/sbomify_action/_generation/generators/cdxgen.py +++ b/sbomify_action/_generation/generators/cdxgen.py @@ -394,10 +394,11 @@ def supports(self, input: GenerationInput) -> bool: if not input.is_docker_image: return False - # 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. + # A scheme-prefixed reference is syft's syntax. generate() invokes + # cdxgen with `-t oci`, which takes a bare image name or archive path + # and knows nothing of `docker:`/`oci-archive:` prefixes, so a + # prefixed value is read as part of the name and fails to resolve. + # Decline, and let the syft generator take it. if image_ref_scheme(input.docker_image): return False diff --git a/sbomify_action/_generation/utils.py b/sbomify_action/_generation/utils.py index a6ab0839..bc68ff30 100644 --- a/sbomify_action/_generation/utils.py +++ b/sbomify_action/_generation/utils.py @@ -645,23 +645,33 @@ def log_progress() -> None: stderr = e.stderr or "" stdout = e.stdout or "" - # Say so when the daemon is the problem. This has to come first: syft - # falls back to the registry when it cannot reach the socket, so the - # tail of the output is a registry error and the not-found check below - # would otherwise claim the image does not exist. - if docker_image and detect_docker_daemon_unreachable(combined_output(stderr, stdout)): + # Both classifications read the whole failure, not just stderr: cdxgen + # writes the sentence that says why to stdout, so a check against + # stderr alone misses it -- see combined_output. + output = combined_output(stderr, stdout) + + # Say so when the daemon is the problem, and let that be the answer. + # syft falls back to the registry when it cannot reach the socket, so + # the tail of the output is a registry error -- commonly "pull access + # denied" or "UNAUTHORIZED" -- which the not-found patterns match. + # Reporting "image not found" there would send the user to check a + # registry and a tag that are both fine, so the not-found + # classification is skipped entirely while the daemon is the known + # cause, rather than merely being preceded by a warning about it. + daemon_unreachable = bool(docker_image) and detect_docker_daemon_unreachable(output) + if daemon_unreachable: 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 " - "container access to /var/run/docker.sock, or scan the image without " - "a daemon by saving it first: " + "or not-found error above is a symptom rather than the cause. Either " + "give the container access to /var/run/docker.sock, or scan the image " + "without a daemon by saving it first: " "`docker save -o image.tar` and DOCKER_IMAGE=docker-archive:image.tar" ) # 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): + if docker_image and not daemon_unreachable and detect_docker_image_not_found(output): logger.warning(f"Docker image '{docker_image}' not found") log_command_error(command_name, stderr, stdout, level="warning") raise DockerImageNotFoundError( @@ -689,7 +699,7 @@ def log_progress() -> None: log_command_error(command_name, stderr, stdout, level="debug") # Include error summary in the exception message for better diagnostics - error_summary = extract_error_summary(combined_output(stderr, stdout)) + error_summary = extract_error_summary(output) message = f"{command_name} command failed with return code {e.returncode}" if error_summary: message += f": {error_summary}" diff --git a/tests/test_docker_image_refs.py b/tests/test_docker_image_refs.py index e86055c7..5be3ba78 100644 --- a/tests/test_docker_image_refs.py +++ b/tests/test_docker_image_refs.py @@ -6,8 +6,13 @@ works today; these tests pin the two things around it that did not. """ +import inspect +import logging +import subprocess + import pytest +from sbomify_action._generation import utils from sbomify_action._generation.generators.cdxgen import CdxgenImageGenerator from sbomify_action._generation.generators.trivy import TrivyImageGenerator from sbomify_action._generation.protocol import GenerationInput @@ -15,6 +20,7 @@ detect_docker_daemon_unreachable, image_ref_scheme, ) +from sbomify_action.exceptions import DockerImageNotFoundError, SBOMGenerationError class TestImageRefScheme: @@ -122,3 +128,100 @@ def test_detects_unreachable_daemon(self, output: str): ) def test_does_not_fire_on_other_failures(self, output: str): assert detect_docker_daemon_unreachable(output) is False + + +class TestDaemonUnreachableWinsOverNotFound: + """The classification, not just the warning. + + Detecting the daemon and then falling through to the not-found check still + reports "image not found": syft's registry fallback emits exactly the + phrases those patterns match -- "pull access denied", "UNAUTHORIZED" -- + so the run ended with the user checking a registry and a tag that were + both correct. The daemon is the known cause, so the not-found + classification is skipped rather than merely preceded by a warning. + """ + + @staticmethod + def _failing(stderr: str = "", stdout: str = "", returncode: int = 1): + """A run_command call whose subprocess fails with this output.""" + + def _run(*args, **kwargs): + raise subprocess.CalledProcessError(returncode, ["syft"], output=stdout, stderr=stderr) + + return _run + + # What syft actually writes when the socket is out of reach and it then + # tries the registry instead. + DAEMON_THEN_REGISTRY = ( + "failed to connect to Docker daemon\n" + "pull access denied for alpine, repository does not exist or may require 'docker login'\n" + ) + + def test_a_daemon_failure_is_not_reported_as_a_missing_image(self, monkeypatch): + monkeypatch.setattr(utils.subprocess, "run", self._failing(stderr=self.DAEMON_THEN_REGISTRY)) + + with pytest.raises(SBOMGenerationError) as excinfo: + utils.run_command(["syft", "alpine:3.20"], "syft", docker_image="alpine:3.20") + + assert not isinstance(excinfo.value, DockerImageNotFoundError) + + def test_the_daemon_is_named_in_the_warning(self, monkeypatch, caplog): + monkeypatch.setattr(utils.subprocess, "run", self._failing(stderr=self.DAEMON_THEN_REGISTRY)) + + with caplog.at_level(logging.WARNING): + with pytest.raises(SBOMGenerationError): + utils.run_command(["syft", "alpine:3.20"], "syft", docker_image="alpine:3.20") + + assert "Docker daemon" in caplog.text + assert "docker save" in caplog.text + + def test_a_genuinely_missing_image_is_still_reported_as_one(self, monkeypatch): + """Without a daemon error in the output, nothing changes.""" + monkeypatch.setattr( + utils.subprocess, + "run", + self._failing(stderr="manifest for alpine:nonexistent not found: manifest unknown"), + ) + + with pytest.raises(DockerImageNotFoundError): + utils.run_command(["syft", "alpine:nonexistent"], "syft", docker_image="alpine:nonexistent") + + def test_not_found_is_detected_on_stdout_too(self, monkeypatch): + """cdxgen writes the sentence that says why to stdout, not stderr. + + The not-found check read `stderr` alone, so a tool that reports the + failure on stdout was classified as a generic command failure. + """ + monkeypatch.setattr( + utils.subprocess, + "run", + self._failing( + stderr="Error running cdxgen:\n", + stdout="manifest for alpine:nonexistent not found: manifest unknown\n", + ), + ) + + with pytest.raises(DockerImageNotFoundError): + utils.run_command(["cdxgen", "alpine:nonexistent"], "cdxgen", docker_image="alpine:nonexistent") + + def test_no_docker_image_means_neither_classification(self, monkeypatch): + """A lock-file run must not be reclassified by a stray phrase.""" + monkeypatch.setattr(utils.subprocess, "run", self._failing(stderr=self.DAEMON_THEN_REGISTRY)) + + with pytest.raises(SBOMGenerationError) as excinfo: + utils.run_command(["syft", "."], "syft") + + assert not isinstance(excinfo.value, DockerImageNotFoundError) + + +class TestCdxgenDeclineComment: + """The comment in supports() must name the flag generate() passes.""" + + def test_the_image_generator_invokes_cdxgen_with_t_oci(self): + source = inspect.getsource(CdxgenImageGenerator.generate) + assert '"oci",' in source + + def test_the_decline_comment_does_not_claim_t_docker(self): + source = inspect.getsource(CdxgenImageGenerator.supports) + assert "-t docker" not in source + assert "-t oci" in source From 0cf582a061fa6fc35ffb0202d72e475b9a1e170a Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:52:19 +0000 Subject: [PATCH 4/7] Merge origin/master, and assert on unresolved pins rather than the word The Integration Tests job was failing on `assert "version_from" not in manifest`, and the build was fine. This branch's own commit added a comment to `tools.toml` explaining the freezing mechanism, and that comment contains the words `version_from`, so a substring check over the whole file read the explanation as an unresolved pin. Confirmed by running the freezer directly: it resolves `uv -> 0.12.19`, returns 0, and the only remaining match is line 28's prose. `UNRESOLVED_PIN` matches `version_from =` at the start of a line, which is what the assertion meant. Checked both ways: the prose is not flagged and a real entry is. Master has no such comment in its manifest, which is why only this branch went red. Also merged origin/master, 187 commits behind. Co-Authored-By: Claude Opus 5 Co-authored-by: multica-agent --- tests/test_tool_manifest.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_tool_manifest.py b/tests/test_tool_manifest.py index 58bd3b0e..3d6a7ed7 100644 --- a/tests/test_tool_manifest.py +++ b/tests/test_tool_manifest.py @@ -33,6 +33,14 @@ DOCKERFILE = REPO_ROOT / "Dockerfile" +# ``version_from = { ... }`` as an actual entry, not the words in a comment. +# The manifest explains its own freezing mechanism in prose, and a substring +# check on the whole file reads that explanation as an unresolved pin -- which +# is what it did: the build freezes correctly and the test failed on the +# sentence describing it. +UNRESOLVED_PIN = re.compile(r"^\s*version_from\s*=", re.MULTILINE) + + def test_versions_come_from_native_lockfiles_on_master(): """Tool versions must not be restated in tools.toml. @@ -106,11 +114,11 @@ def test_a_built_wheel_carries_resolved_versions(tmp_path): # extraction is not something to leave to chance. assert [n for n in names if n.endswith("tools.toml")] == ["sbomify_action/tools.toml"] manifest = wheel.read("sbomify_action/tools.toml").decode() - assert "version_from" not in manifest, "the wheel still resolves a version from a lockfile it does not ship" + assert not UNRESOLVED_PIN.search(manifest), "the wheel still resolves a version from a lockfile it does not ship" # Frozen for the artifact only: freezing the source tree in place would put # the versions back out of Dependabot's reach. - assert "version_from" in (root / "sbomify_action" / "tools.toml").read_text() + assert UNRESOLVED_PIN.search((root / "sbomify_action" / "tools.toml").read_text()) @pytest.mark.slow @@ -152,14 +160,14 @@ def test_a_built_sdist_carries_resolved_versions(tmp_path): member = sdist.extractfile(f"{prefix}/sbomify_action/tools.toml") assert member is not None manifest = member.read().decode() - assert "version_from" not in manifest, "the sdist still resolves a version from a lockfile it does not ship" + assert not UNRESOLVED_PIN.search(manifest), "the sdist still resolves a version from a lockfile it does not ship" assert f"{prefix}/hatch_build.py" in names assert f"{prefix}/scripts/freeze_tool_versions.py" in names # Same as the wheel: frozen for the artifact only, so the versions stay # where Dependabot can reach them. - assert "version_from" in (root / "sbomify_action" / "tools.toml").read_text() + assert UNRESOLVED_PIN.search((root / "sbomify_action" / "tools.toml").read_text()) def test_lockfiles_are_the_ones_dependabot_watches(): From 3c67d8f00bb6f13528025e741d63f19f318fcc91 Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:05:23 +0000 Subject: [PATCH 5/7] Pin the action to the 26.9.0 image, and say how a release bumps it Co-Authored-By: Claude Opus 5.5 --- action.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 34bed735..a5358f6d 100644 --- a/action.yml +++ b/action.yml @@ -43,7 +43,11 @@ runs: # # `:latest` was also the wrong tag to float on: it is published before the # versioned tag, so it lags the release it is meant to represent. - image: 'docker://ghcr.io/sbomify/sbomify-action:26.8.0@sha256:b89230442778f50a24b27b55b373cdbaad042f5ffb27d9af195e99ed3bd2e7bb' + # + # The digest only exists once the release's image is published, so a + # release is two steps: tag and publish the image, then pin its digest here + # in a follow-up commit. Until that lands, this points at the previous image. + image: 'docker://ghcr.io/sbomify/sbomify-action:26.9.0@sha256:f5355ed44e6618d3fd9e8640517d03d7a58eb1e2137c533ba5c24cd92676be98' env: WORKING_DIR: ${{ inputs['working-dir'] }} COMPONENT_PURL: ${{ inputs['component-purl'] }} From 1558a55302d909a0e1317e376175c062a36df262 Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:03:21 +0000 Subject: [PATCH 6/7] Keep Conan's plugins and config read-only for caller uids chmod -R a+rwX on $HOME also made CONAN_HOME's extensions/plugins (profile.py, compatibility.py) and its profiles/global.conf writable by any uid, so one process could swap code a later conan call loads. Only the package cache (p/) stays fully writable now. CONAN_HOME is sticky, so any uid can still create .conan.db there but cannot unlink and replace root's files. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 73b09f38..377540a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -138,11 +138,20 @@ ENV HOME=/home/sbomify # writable, not merely readable, or every pkg:conan/* lookup degrades to a # warning. # -# Kept under $HOME rather than a second world-writable tree in /opt: that -# one would contain conan's executable plugins (profile.py, -# compatibility.py), which is a code-execution surface this does not need. +# Only the runtime state is writable, though. Conan's executable plugins +# (extensions/plugins/profile.py, compatibility.py) and its config (profiles, +# global.conf, settings.yml, remotes.json) stay root-owned and read-only, so +# no caller uid can swap code that a later conan call, root's included, loads. +# The package cache (p/) is fully writable, and CONAN_HOME itself is sticky +# like /tmp: any uid can create .conan.db there, but none can unlink and +# replace root's files. Checked against conan 2.32 with everything but p/ +# read-only: a pkg:conan lookup still resolves. ENV CONAN_HOME=/home/sbomify/.conan2 -RUN conan profile detect --force && chmod -R a+rwX /home/sbomify +RUN conan profile detect --force \ + && chmod -R a+rwX /home/sbomify \ + && chmod -R go-w "$CONAN_HOME" \ + && chmod a+rwxt "$CONAN_HOME" \ + && chmod -R a+rwX "$CONAN_HOME/p" # Marks our own image, which two behaviours key off: # - runtime tools are fetched on demand only in here, where we decide the From 019f505461f95329819b8a09d1a9e42160518d31 Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:02:54 +0000 Subject: [PATCH 7/7] Make HOME sticky so .conan2 cannot be swapped out A world-writable, non-sticky /home/sbomify let any caller uid rename the root-owned .conan2 aside and drop a writable tree in its place, undoing the read-only plugins and config. Sticky HOME still lets any uid create .sigstore and caches and write existing files, but only the owner can rename or unlink a root-owned entry. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index 377540a9..7de314e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -146,9 +146,15 @@ ENV HOME=/home/sbomify # like /tmp: any uid can create .conan.db there, but none can unlink and # replace root's files. Checked against conan 2.32 with everything but p/ # read-only: a pkg:conan lookup still resolves. +# +# HOME is sticky too, or none of that holds: in a world-writable HOME any uid +# can rename .conan2 aside and put a writable tree of its own in its place. +# Sticky keeps creation open (.sigstore, caches) and existing files writable, +# but only the owner can rename or unlink a root-owned entry. ENV CONAN_HOME=/home/sbomify/.conan2 RUN conan profile detect --force \ && chmod -R a+rwX /home/sbomify \ + && chmod +t /home/sbomify \ && chmod -R go-w "$CONAN_HOME" \ && chmod a+rwxt "$CONAN_HOME" \ && chmod -R a+rwX "$CONAN_HOME/p"