Skip to content
Open
72 changes: 67 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,17 @@ LABEL com.sbomify.maintainer="sbomify <[email protected]>" \
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.
Expand All @@ -93,9 +100,64 @@ 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 <uid>:<gid>` 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.
#
# 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.
#
# 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"

# Marks our own image, which two behaviours key off:
# - runtime tools are fetched on demand only in here, where we decide the
Expand Down
17 changes: 15 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,21 @@ 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@<sha>` -- 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.
#
# 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'] }}
Expand Down
9 changes: 9 additions & 0 deletions sbomify_action/_generation/generators/cdxgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ensure_php_installed,
get_lock_file_ecosystem,
has_required_manifest,
image_ref_scheme,
resolve_npm_lockfile,
run_command,
)
Expand Down Expand Up @@ -393,6 +394,14 @@ def supports(self, input: GenerationInput) -> bool:
if not input.is_docker_image:
return False

# 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

# Only supports CycloneDX format (cdxgen doesn't output SPDX)
if input.output_format != "cyclonedx":
return False
Expand Down
10 changes: 9 additions & 1 deletion sbomify_action/_generation/generators/trivy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 <path>`, 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
Expand Down
82 changes: 80 additions & 2 deletions sbomify_action/_generation/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,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 <path>` 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 = [
Expand Down Expand Up @@ -592,9 +646,33 @@ def log_progress() -> None:
stderr = e.stderr or ""
stdout = e.stdout or ""

# 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 "
"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 <image> -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(
Expand Down Expand Up @@ -622,7 +700,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}"
Expand Down
25 changes: 20 additions & 5 deletions sbomify_action/tools.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,26 @@
# 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.
tools_release = "tools-rolling"
# Which sbom-tools release to fetch from.
#
# 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.
#
# 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.
#
# 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
Expand Down
Loading
Loading