From 7c0f9f822280e09cb9cfcb4ba8aac74873a8ca6e Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 26 Jun 2026 19:04:22 -0400 Subject: [PATCH 1/2] verify: verify vendored node_modules against the npm registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node actions that commit their whole node_modules tree were only checked by rebuilding with `npm ci` and diffing. npm is not byte-reproducible across versions, so the rebuild frequently fails to match and the tool falls back to a weak "diff against the previously approved version" review prompt (seen most recently on dawidd6/action-send-mail v17→v18, which is just a nodemailer 8→9 vendored refresh). Add a deterministic, registry-anchored check. For every package in the committed `node_modules/.package-lock.json` (lockfileVersion 2/3), it: 1. downloads the tarball from the lockfile's `resolved` URL, 2. confirms the tarball digest matches the lockfile `integrity` — proving it is the published, untampered package, 3. compares every file the tarball contains against the committed blob by git blob SHA (so committed blobs are never downloaded — the repo tree already carries them), 4. flags any committed file inside a verified package directory that the tarball does not contain (injected code). Packages that cannot be registry-verified (git/file/link deps or a missing integrity) are reported as skipped, never silently passed; a truncated repo tree refuses to claim a pass; non-npmjs registries are flagged. The check is wired into the verification summary as "Vendored npm registry check" and folds into the overall verdict. Needs network access to registry.npmjs.org. Generated-by: Claude Opus 4.8 (1M context) --- .../test_npm_registry_verify.py | 172 +++++++++ .../npm_registry_verify.py | 348 ++++++++++++++++++ utils/verify_action_build/verification.py | 41 +++ 3 files changed, 561 insertions(+) create mode 100644 utils/tests/verify_action_build/test_npm_registry_verify.py create mode 100644 utils/verify_action_build/npm_registry_verify.py diff --git a/utils/tests/verify_action_build/test_npm_registry_verify.py b/utils/tests/verify_action_build/test_npm_registry_verify.py new file mode 100644 index 000000000..1e559c61a --- /dev/null +++ b/utils/tests/verify_action_build/test_npm_registry_verify.py @@ -0,0 +1,172 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Tests for the vendored-node_modules npm registry verification.""" + +import base64 +import hashlib +import io +import json +import tarfile +from unittest import mock + +from verify_action_build import npm_registry_verify as nrv +from verify_action_build.npm_registry_verify import ( + _git_blob_sha1, + _integrity_matches, + _tarball_files, + verify_vendored_node_modules, +) + + +def _make_tgz(files: dict[str, bytes]) -> bytes: + """Build an npm-style ``.tgz`` (everything under ``package/``).""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for rel, content in files.items(): + info = tarfile.TarInfo(name=f"package/{rel}") + info.size = len(content) + tf.addfile(info, io.BytesIO(content)) + return buf.getvalue() + + +def _integrity(data: bytes) -> str: + return "sha512-" + base64.b64encode(hashlib.sha512(data).digest()).decode() + + +# A single-package fixture mirroring the real shape (action-send-mail vendors +# small packages exactly like this). +PKG_FILES = { + "index.js": b"module.exports = 1;\n", + "package.json": b'{"name":"foo","version":"1.0.0"}\n', +} +PKG_TGZ = _make_tgz(PKG_FILES) +PKG_URL = "https://registry.npmjs.org/foo/-/foo-1.0.0.tgz" + + +def _tree_for(files: dict[str, bytes], extra: dict[str, str] | None = None) -> dict[str, str]: + tree = {"node_modules/.package-lock.json": "abc123"} + for rel, content in files.items(): + tree[f"node_modules/foo/{rel}"] = _git_blob_sha1(content) + if extra: + tree.update(extra) + return tree + + +def _lock(resolved: str = PKG_URL, integrity: str | None = None, extra_pkgs=None) -> bytes: + packages = { + "": {"name": "root"}, + "node_modules/foo": { + "version": "1.0.0", + "resolved": resolved, + "integrity": integrity if integrity is not None else _integrity(PKG_TGZ), + }, + } + if extra_pkgs: + packages.update(extra_pkgs) + return json.dumps({"lockfileVersion": 3, "packages": packages}).encode() + + +def _run(tree, lockfile_bytes, tarballs=None, truncated=False): + """Invoke the verifier with the three network seams mocked.""" + tarballs = tarballs or {PKG_URL: PKG_TGZ} + with mock.patch.object(nrv, "_fetch_tree_with_sha", return_value=(tree, truncated)), \ + mock.patch.object(nrv, "_fetch_lockfile", return_value=lockfile_bytes), \ + mock.patch.object(nrv, "_download_tarball", side_effect=lambda url: tarballs.get(url)): + return verify_vendored_node_modules("org", "repo", "deadbeef") + + +class TestHelpers: + def test_git_blob_sha1_known_value(self): + # git hash-object of an empty blob is well-known. + assert _git_blob_sha1(b"") == "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + + def test_integrity_matches_true_and_false(self): + data = b"hello" + assert _integrity_matches(data, _integrity(data)) is True + assert _integrity_matches(data, _integrity(b"tampered")) is False + + def test_integrity_matches_sha256_token(self): + data = b"hello" + b64 = base64.b64encode(hashlib.sha256(data).digest()).decode() + assert _integrity_matches(data, f"sha256-{b64}") is True + + def test_tarball_files_strips_package_prefix(self): + out = _tarball_files(PKG_TGZ) + assert out == PKG_FILES + + +class TestVerify: + def test_no_vendored_lockfile_returns_none(self): + # Tree without node_modules/.package-lock.json → not applicable. + assert _run({"action.yml": "x", "index.js": "y"}, _lock()) is None + + def test_clean_match_passes(self): + result = _run(_tree_for(PKG_FILES), _lock()) + assert result is not None + assert result.ok is True + assert result.verified == ["foo"] + assert not result.mismatched and not result.extra and not result.errors + + def test_content_mismatch_fails(self): + tree = _tree_for(PKG_FILES) + tree["node_modules/foo/index.js"] = _git_blob_sha1(b"EVIL();\n") # tampered + result = _run(tree, _lock()) + assert result.ok is False + assert "node_modules/foo/index.js" in result.mismatched + assert "foo" not in result.verified + + def test_integrity_mismatch_fails(self): + # Lockfile claims a digest the tarball doesn't have → reject. + result = _run(_tree_for(PKG_FILES), _lock(integrity=_integrity(b"other"))) + assert result.ok is False + assert any("integrity" in e for e in result.errors) + + def test_extra_file_in_verified_package_fails(self): + tree = _tree_for(PKG_FILES, extra={"node_modules/foo/sneaky.js": "deadbeef00"}) + result = _run(tree, _lock()) + assert result.ok is False + assert "node_modules/foo/sneaky.js" in result.extra + + def test_noisy_bin_files_not_flagged_as_extra(self): + tree = _tree_for(PKG_FILES, extra={"node_modules/.bin/foo": "shimsha00"}) + result = _run(tree, _lock()) + assert result.ok is True + assert not result.extra + + def test_git_dependency_is_skipped_not_failed(self): + # A git dep has no integrity / non-registry resolved → skipped, not a + # hard failure, but surfaced (no silent pass). + lock = _lock(resolved="git+ssh://git@github.com/foo/foo.git#abc", integrity="") + result = _run(_tree_for(PKG_FILES), lock) + assert result.ok is True + assert "foo" in result.skipped + assert "foo" not in result.verified + + def test_foreign_registry_recorded(self): + url = "https://npm.example.com/foo/-/foo-1.0.0.tgz" + result = _run(_tree_for(PKG_FILES), _lock(resolved=url), tarballs={url: PKG_TGZ}) + assert result.ok is True + assert any("npm.example.com" in f for f in result.foreign) + assert "foo" in result.verified + + def test_truncated_tree_cannot_pass(self): + result = _run(_tree_for(PKG_FILES), _lock(), truncated=True) + assert result is not None + assert result.truncated is True + assert result.ok is False diff --git a/utils/verify_action_build/npm_registry_verify.py b/utils/verify_action_build/npm_registry_verify.py new file mode 100644 index 000000000..9b06389fb --- /dev/null +++ b/utils/verify_action_build/npm_registry_verify.py @@ -0,0 +1,348 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Verify vendored ``node_modules`` against the npm registry. + +Many node actions commit their whole ``node_modules`` tree. The existing +JS-build check rebuilds that tree with ``npm ci`` and diffs it — but npm is +not byte-reproducible across versions, so the rebuild frequently fails to +match and the tool falls back to a weak "diff against the previously +approved version" review prompt. + +This module verifies the vendored tree directly against the registry, +which *is* deterministic. npm records, for every package in the +``lockfileVersion`` 2/3 lockfile, the ``resolved`` tarball URL and an +``integrity`` digest (sha512 of the tarball). For each package we: + + 1. download the tarball from ``resolved``, + 2. confirm its digest matches ``integrity`` — proving it is the + published, untampered package, + 3. compare every file the tarball contains against the committed + ``node_modules//...`` blob (by git blob SHA, so committed blobs + never have to be downloaded — the repo tree already carries them), + 4. flag any committed file inside a verified package directory that the + tarball does *not* contain (injected code). + +Packages that cannot be registry-verified (git/file/link deps, or a +missing ``integrity``) are reported as *skipped* rather than silently +passed. A pass means: every vendored package matched a registry tarball +whose digest we verified, with no extra files. +""" + +import base64 +import hashlib +import io +import json +import os +import tarfile + +import requests + +from .console import console, link + +# npm-generated artifacts that live in a vendored ``node_modules`` but do +# not come from any package tarball, so they are not part of verification. +_NOISY_NAMES = {".package-lock.json", ".yarn-integrity"} +_NOISY_DIRS = {".bin", ".cache"} + +NPM_REGISTRY_HOST = "registry.npmjs.org" + + +class NpmRegistryResult: + """Outcome of verifying a vendored ``node_modules`` tree. + + ``verified`` / ``skipped`` / ``foreign`` hold package names; + ``mismatched`` / ``extra`` / ``errors`` hold human-readable detail + strings. ``ok`` is the hard verdict; ``skipped``/``foreign`` are + surfaced but do not fail on their own. + """ + + def __init__(self) -> None: + self.verified: list[str] = [] + self.skipped: list[str] = [] + self.foreign: list[str] = [] + self.mismatched: list[str] = [] + self.extra: list[str] = [] + self.errors: list[str] = [] + self.truncated: bool = False + + @property + def total(self) -> int: + return len(self.verified) + len(self.skipped) + + @property + def ok(self) -> bool: + return ( + not self.truncated + and not self.mismatched + and not self.extra + and not self.errors + ) + + +def _git_blob_sha1(data: bytes) -> str: + """Git's blob object id: sha1 of ``blob \\0``. + + Lets us compare a tarball's extracted file against the committed blob + using only the SHA the repo tree already reports — no blob download. + """ + h = hashlib.sha1() + h.update(b"blob " + str(len(data)).encode() + b"\0") + h.update(data) + return h.hexdigest() + + +def _integrity_matches(data: bytes, integrity: str) -> bool: + """Check tarball bytes against a Subresource-Integrity ``algo-b64`` string. + + ``integrity`` may carry several space-separated digests; a match on any + recognised algorithm (sha512/sha256/sha1) is sufficient. + """ + for token in integrity.split(): + algo, _, b64 = token.partition("-") + if not b64 or algo not in ("sha512", "sha256", "sha1"): + continue + digest = hashlib.new(algo, data).digest() + if base64.b64encode(digest).decode() == b64: + return True + return False + + +def _fetch_tree_with_sha(org: str, repo: str, commit_hash: str) -> tuple[dict[str, str], bool]: + """Map every blob path at ``commit_hash`` to its git blob SHA. + + Returns ``(paths, truncated)``. ``truncated`` is True when the tree + exceeded the API's recursive listing limit — the result is then not + canonical and the caller must not treat absence as proof of anything. + """ + url = f"https://api.github.com/repos/{org}/{repo}/git/trees/{commit_hash}?recursive=1" + headers = {"Accept": "application/vnd.github+json"} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + try: + resp = requests.get(url, timeout=15, headers=headers) + if not resp.ok: + return {}, False + data = resp.json() + paths = { + t["path"]: t["sha"] + for t in data.get("tree", []) + if t.get("type") == "blob" + } + return paths, bool(data.get("truncated")) + except (requests.RequestException, ValueError): + return {}, False + + +def _fetch_lockfile(org: str, repo: str, commit_hash: str, path: str) -> bytes | None: + """Fetch one file's raw bytes at ``commit_hash`` via raw.githubusercontent.""" + url = f"https://raw.githubusercontent.com/{org}/{repo}/{commit_hash}/{path}" + headers = {} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + try: + resp = requests.get(url, timeout=15, headers=headers) + return resp.content if resp.ok else None + except requests.RequestException: + return None + + +def _download_tarball(url: str) -> bytes | None: + """Download a package tarball (public npm registry needs no auth).""" + try: + resp = requests.get(url, timeout=30) + return resp.content if resp.ok else None + except requests.RequestException: + return None + + +def _tarball_files(data: bytes) -> dict[str, bytes]: + """Extract a ``.tgz`` into ``{relative_path: bytes}``. + + npm tarballs root everything under ``package/``; that prefix is + stripped so paths line up with ``node_modules//``. + """ + files: dict[str, bytes] = {} + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + name = member.name + rel = name[len("package/"):] if name.startswith("package/") else name + extracted = tf.extractfile(member) + if extracted is None: + continue + files[rel] = extracted.read() + return files + + +def _is_noisy(rel_path: str) -> bool: + parts = rel_path.split("/") + return parts[-1] in _NOISY_NAMES or any(p in _NOISY_DIRS for p in parts) + + +def verify_vendored_node_modules( + org: str, repo: str, commit_hash: str, sub_path: str = "", +) -> NpmRegistryResult | None: + """Verify a committed ``node_modules`` tree against the npm registry. + + Returns ``None`` when the action ships no vendored lockfile (the check + is not applicable). Otherwise returns an :class:`NpmRegistryResult`. + """ + prefix = f"{sub_path.rstrip('/')}/" if sub_path else "" + nm_prefix = f"{prefix}node_modules/" + lock_path = f"{nm_prefix}.package-lock.json" + + tree, truncated = _fetch_tree_with_sha(org, repo, commit_hash) + if not tree: + return None + if lock_path not in tree: + return None + + result = NpmRegistryResult() + if truncated: + # The tree was too large to enumerate fully; we cannot reason about + # extra files, so refuse to claim a pass. + result.truncated = True + return result + + raw = _fetch_lockfile(org, repo, commit_hash, lock_path) + if raw is None: + result.errors.append(f"could not fetch {lock_path}") + return result + try: + lock = json.loads(raw) + except ValueError: + result.errors.append(f"{lock_path} is not valid JSON") + return result + + packages = lock.get("packages") + if not isinstance(packages, dict): + # lockfileVersion 1 (only "dependencies", no per-package integrity + # under "packages") — out of scope for registry verification. + result.skipped.append("(lockfileVersion < 2 — no per-package integrity)") + return result + + console.print() + console.rule("[bold]Vendored npm Registry Check[/bold]") + + # Every committed file under node_modules/, by repo-relative path → sha. + committed = { + path[len(prefix):]: sha + for path, sha in tree.items() + if path.startswith(nm_prefix) + } + + tarball_cache: dict[str, dict[str, bytes] | None] = {} + # node_modules paths that a verified tarball legitimately accounts for. + accounted: set[str] = set() + # node_modules dirs belonging to a package we managed to verify. + verified_dirs: list[str] = [] + + for key, meta in packages.items(): + if not key.startswith("node_modules/"): + continue # "" is the root project itself + if not isinstance(meta, dict): + continue + name = key[len("node_modules/"):] + resolved = meta.get("resolved") or "" + integrity = meta.get("integrity") or "" + + if not integrity or not resolved.startswith(("http://", "https://")): + # git/file/link dependency, or a workspace package — no tarball + # digest to verify against. + result.skipped.append(name) + continue + + host = resolved.split("/", 3)[2] if "://" in resolved else "" + if host != NPM_REGISTRY_HOST: + result.foreign.append(f"{name} (registry: {host})") + + if integrity not in tarball_cache: + data = _download_tarball(resolved) + if data is None: + tarball_cache[integrity] = None + result.errors.append(f"{name}: could not download {resolved}") + continue + if not _integrity_matches(data, integrity): + tarball_cache[integrity] = None + result.errors.append( + f"{name}: tarball digest does not match lockfile integrity" + ) + continue + tarball_cache[integrity] = _tarball_files(data) + + tar_files = tarball_cache[integrity] + if tar_files is None: + continue # already recorded as error above + + verified_dirs.append(key + "/") + pkg_ok = True + for rel, content in tar_files.items(): + committed_path = f"{key}/{rel}" + accounted.add(committed_path) + committed_sha = committed.get(committed_path) + if committed_sha is None: + continue # tarball ships a file the repo omits — benign + if committed_sha != _git_blob_sha1(content): + result.mismatched.append(committed_path) + pkg_ok = False + if pkg_ok: + result.verified.append(name) + + # Any committed file that sits inside a verified package directory but + # was not produced by that package's tarball is injected code. + for path in committed: + if _is_noisy(path): + continue + if path in accounted: + continue + if any(path.startswith(d) for d in verified_dirs): + result.extra.append(path) + + _render(result, org, repo, commit_hash) + return result + + +def _render(result: NpmRegistryResult, org: str, repo: str, commit_hash: str) -> None: + """Print a per-category summary of the registry check.""" + blob = f"https://github.com/{org}/{repo}/tree/{commit_hash}/node_modules" + if result.verified: + console.print( + f" [green]✓[/green] {len(result.verified)} package(s) match " + f"registry-published, integrity-verified tarballs" + ) + for name in result.foreign: + console.print(f" [yellow]![/yellow] {name} — non-npmjs registry") + if result.skipped: + console.print( + f" [yellow]![/yellow] {len(result.skipped)} package(s) not " + f"registry-verifiable (git/file/link dep or missing integrity): " + f"{', '.join(result.skipped[:8])}" + + (" …" if len(result.skipped) > 8 else "") + ) + for path in result.mismatched: + console.print(f" [red]✗[/red] {link(path, f'{blob}')} — content differs from registry tarball") + for path in result.extra: + console.print(f" [red]✗[/red] {path} — present in repo but not in the verified package tarball") + for err in result.errors: + console.print(f" [red]✗[/red] {err}") + if result.truncated: + console.print(" [yellow]![/yellow] repo tree too large to enumerate — cannot verify") diff --git a/utils/verify_action_build/verification.py b/utils/verify_action_build/verification.py index 0c1a7b7f6..1291e57b7 100644 --- a/utils/verify_action_build/verification.py +++ b/utils/verify_action_build/verification.py @@ -33,6 +33,7 @@ from .diff_source import diff_approved_vs_new from .docker_build import build_in_docker from .github_client import GitHubClient +from .npm_registry_verify import verify_vendored_node_modules from .release_lookup import ( format_release_time, get_release_or_commit_time, @@ -190,6 +191,7 @@ def verify_single_action( binary_download_failures: list[str] = [] lock_file_errors: list[str] = [] in_tree_binary_errors: list[str] = [] + npm_registry_failed = False # Detect source-detached release tags (orphan commits containing only # distributable artifacts) and resolve the default-branch source commit @@ -340,6 +342,38 @@ def verify_single_action( "no in-tree binaries (or all verified via attestation / SHA256SUMS)", )) + # Vendored npm dependency check: when an action commits its + # node_modules tree, verify every package directly against the npm + # registry (tarball integrity + file-by-file), which is + # deterministic — unlike the npm-ci rebuild that the JS-build check + # relies on. Returns None when nothing is vendored. + npm_registry_result = verify_vendored_node_modules( + org, repo, commit_hash, sub_path, + ) + if npm_registry_result is not None: + npm_registry_failed = not npm_registry_result.ok + if npm_registry_result.ok: + detail = ( + f"{len(npm_registry_result.verified)} vendored package(s) " + f"match registry-verified tarballs" + ) + if npm_registry_result.skipped: + detail += f"; {len(npm_registry_result.skipped)} not registry-verifiable" + status = "warn" if ( + npm_registry_result.skipped or npm_registry_result.foreign + ) else "pass" + checks_performed.append(("Vendored npm registry check", status, detail)) + else: + if npm_registry_result.truncated: + detail = "repo tree too large to enumerate — cannot verify" + else: + detail = ( + f"{len(npm_registry_result.mismatched)} modified, " + f"{len(npm_registry_result.extra)} extra, " + f"{len(npm_registry_result.errors)} error(s) vs registry" + ) + checks_performed.append(("Vendored npm registry check", "fail", detail)) + if not is_js_action: console.print() console.print( @@ -558,6 +592,7 @@ def verify_single_action( and not binary_download_failures and not lock_file_errors and not in_tree_binary_errors + and not npm_registry_failed ) console.print() @@ -613,6 +648,12 @@ def verify_single_action( f"in repo (no SLSA attestation, no matching SHA256SUMS at release)" f"[/red bold]" ) + elif npm_registry_failed: + fail_msg = ( + f"[red bold]{action_type} action — vendored node_modules do not " + f"match registry-published packages (modified or extra files, or " + f"integrity mismatch)[/red bold]" + ) else: fail_msg = f"[red bold]{action_type} action — verification failed[/red bold]" console.print( From 56c73359fe0dd679e5072aa5946f765a9d5016f8 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 26 Jun 2026 22:56:39 -0400 Subject: [PATCH 2/2] test: tighten foreign-registry assertion to exact match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `"npm.example.com" in f` substring check with an exact-equality assertion on the foreign-registry entry. Stronger assertion, and clears CodeQL's incomplete-URL-substring-sanitization alert (a false positive in test context — production host comparison in npm_registry_verify.py is an exact `!=` on the parsed host). Generated-by: Claude Opus 4.8 (1M context) --- utils/tests/verify_action_build/test_npm_registry_verify.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/utils/tests/verify_action_build/test_npm_registry_verify.py b/utils/tests/verify_action_build/test_npm_registry_verify.py index 1e559c61a..1362c8f23 100644 --- a/utils/tests/verify_action_build/test_npm_registry_verify.py +++ b/utils/tests/verify_action_build/test_npm_registry_verify.py @@ -162,7 +162,9 @@ def test_foreign_registry_recorded(self): url = "https://npm.example.com/foo/-/foo-1.0.0.tgz" result = _run(_tree_for(PKG_FILES), _lock(resolved=url), tarballs={url: PKG_TGZ}) assert result.ok is True - assert any("npm.example.com" in f for f in result.foreign) + # Exact match (not a substring check) — asserts the precise foreign + # entry and avoids CodeQL's URL-substring-sanitization heuristic. + assert result.foreign == ["foo (registry: npm.example.com)"] assert "foo" in result.verified def test_truncated_tree_cannot_pass(self):