diff --git a/Dockerfile b/Dockerfile index ba4fb7eb..7de314e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,10 +74,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. @@ -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 :` 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 diff --git a/action.yml b/action.yml index 32d3e636..a5358f6d 100644 --- a/action.yml +++ b/action.yml @@ -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@` -- 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'] }} diff --git a/sbomify_action/_generation/generators/cdxgen.py b/sbomify_action/_generation/generators/cdxgen.py index 7c1a3ebe..94d8a504 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,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 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 49590898..1b417400 100644 --- a/sbomify_action/_generation/utils.py +++ b/sbomify_action/_generation/utils.py @@ -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 ` 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 = [ @@ -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 -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( @@ -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}" diff --git a/sbomify_action/tools.toml b/sbomify_action/tools.toml index 4c2cf678..d12f1dd6 100644 --- a/sbomify_action/tools.toml +++ b/sbomify_action/tools.toml @@ -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 diff --git a/tests/test_docker_image_refs.py b/tests/test_docker_image_refs.py new file mode 100644 index 00000000..5be3ba78 --- /dev/null +++ b/tests/test_docker_image_refs.py @@ -0,0 +1,227 @@ +"""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 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 +from sbomify_action._generation.utils import ( + detect_docker_daemon_unreachable, + image_ref_scheme, +) +from sbomify_action.exceptions import DockerImageNotFoundError, SBOMGenerationError + + +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 + + +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 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():