From 1f40ac2b15fc4ebfe94077a115761b1e1e903236 Mon Sep 17 00:00:00 2001 From: Christopher Kevin Date: Wed, 28 Oct 2026 13:17:01 -0700 Subject: [PATCH 1/4] fix(supply-chain): scope npm lock resolution to owning projects Signed-off-by: Christopher Kevin --- CHANGELOG.md | 1 + .../analyzers/static_patterns_supply_chain.py | 103 ++++++++++++---- ..._static_patterns_supply_chain_lockfiles.py | 113 ++++++++++++++++++ tests/unit/test_patterns_new.py | 4 +- 4 files changed, 193 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0057c64b..068bd0209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ### 2.12.0 (Unreleased) ### Features/Bug Fixes * fix(coverage): account for successfully inspected linked ZIP containers while retaining member failures and archive limits +* fix(supply-chain): resolve npm manifest dependencies within their owning lockfile and workspace installation paths * fix(static): stop Markdown code fence lines from reading as an open quote that merges consecutive shell lines into one marker directive (#868) * fix(ra1): treat a prohibition on recommending a self-update ("Do not recommend self-update") as the prohibition it is, not as an RA1 self-modification instruction (#928) * fix(analyzer): stop PowerShell comments, `<# #>` blocks, backtick line continuations and display-only `Write-Host`/`Write-Verbose`-style messages from reporting a false `static_parse_limit`; payloads for `bash -c`, `Invoke-Expression`, script blocks and call operators, subexpressions inside messages, and any source whose token ownership cannot be proven keep their existing bounded checks and partial status (#711) diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index bcbc5fe1f..de0b5fdda 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -43,9 +43,10 @@ import time import tomllib from bisect import bisect_right -from collections.abc import Callable, Iterator +from collections import ChainMap +from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from urllib.parse import urlparse from packaging.requirements import InvalidRequirement, Requirement @@ -2036,7 +2037,7 @@ def _extract_packages_from_toml_lock( def _apply_locked_versions( packages: list[tuple[str, str | None, int]], - locked_versions: dict[str, str] | None, + locked_versions: Mapping[str, str] | None, normalize: Callable[[str], str] = _normalize_package_name, ) -> list[tuple[str, str | None, int]]: """Prefer lockfile versions for manifest dependencies without exact versions.""" @@ -2189,20 +2190,21 @@ def _collect_npm_locked_versions( *, max_files: int | None = None, timeout_seconds: float | None = None, -) -> dict[str, str]: - """Build package -> exact version map from npm lockfiles in the project. - - Kept separate from the Python map: the two ecosystems share package names (``semver``, - ``packaging``, ``requests`` all exist in both), so a single map would resolve a PyPI - dependency against an npm version, and vice versa. +) -> dict[str, Mapping[str, str]]: + """Resolve manifest pins within their own lockfile's installation tree. - Bounded by the same file count and clock the Python collector is bounded by. A lockfile is - attacker-shaped input — a skill can ship one — and an unbounded parse next to a bounded one - is not half-safe, it is the way in: the cap that matters is the smallest one, and here that - would have been no cap at all. + Workspace manifests may use hoisted installs from their lock root, but unrelated + projects must never borrow those pins. Keep each lockfile's independent vulnerability + scan separate: nested installs still need auditing even when no manifest resolves to them. """ - locked_versions: dict[str, str] = {} - depths: dict[str, int] = {} + locked_versions: dict[str, Mapping[str, str]] = {} + owners = { + str(Path(path).parent): len(Path(path).parent.parts) + for path in components + if _is_npm_lockfile(path) + } + component_set = set(components) + manifest_dirs = {str(Path(path).parent) for path in components if path.endswith("package.json")} started_at = time.monotonic() files_read = 0 for path in components: @@ -2212,18 +2214,67 @@ def _collect_npm_locked_versions( break if timeout_seconds is not None and time.monotonic() - started_at >= timeout_seconds: break + root = Path(path).parent + if ( + Path(path).name == "package-lock.json" + and str(root / "npm-shrinkwrap.json") in component_set + ): + continue # npm gives shrinkwrap precedence when both files exist. content = file_cache.get(path) if content is None: continue files_read += 1 - for name, version, _line_num, depth in _npm_lock_entries(content): - key = _normalize_npm_package_name(name) - # A manifest range names a *direct* dependency, so it resolves to the top-level - # install. Nested copies exist for other packages' constraints; answering with one - # would report a version this manifest never asked for. - if key not in depths or depth < depths[key]: - locked_versions[key] = version - depths[key] = depth + try: + data = json.loads(content) + except (ValueError, TypeError): + continue + if not isinstance(data, dict): + continue + installs: dict[PurePosixPath, dict[str, str]] = {} + projects = {PurePosixPath(".")} + packages = data.get("packages") + if isinstance(packages, dict): + for location, entry in packages.items(): + relative = PurePosixPath(location) + if ( + not location + or relative.is_absolute() + or ".." in relative.parts + or "\\" in location + ): + continue + if not isinstance(entry, dict): + continue + if str(root / relative) in manifest_dirs: + projects.add(relative) + owner, separator, name = location.rpartition("node_modules/") + version = entry.get("version") + if ( + separator + and (not owner or owner.endswith("/")) + and isinstance(version, str) + and version.strip() + ): + installs.setdefault(PurePosixPath(owner), {})[ + _normalize_npm_package_name(name) + ] = version.strip() + elif isinstance(data.get("dependencies"), dict): + installs[PurePosixPath(".")] = { + _normalize_npm_package_name(name): entry["version"].strip() + for name, entry in data["dependencies"].items() + if isinstance(entry, dict) + and isinstance(entry.get("version"), str) + and entry["version"].strip() + } + for project in projects: + scope = str(root / project) + depth = len(root.parts) + if depth < owners.get(scope, -1): + continue + owners[scope] = depth + locked_versions[scope] = ChainMap( + *(installs[parent] for parent in (project, *project.parents) if parent in installs) + ) return locked_versions @@ -3167,7 +3218,7 @@ def _analyze_dependencies( content: str, file_path: str, locked_versions: dict[str, str] | None = None, - npm_locked_versions: dict[str, str] | None = None, + npm_locked_versions: Mapping[str, str] | None = None, ) -> list[AnalyzerFinding]: """Run SC4/SC5/SC6 checks on dependency files.""" findings, _limitations, _packages_seen = _analyze_dependencies_detailed( @@ -3183,7 +3234,7 @@ def _analyze_dependencies_detailed( content: str, file_path: str, locked_versions: dict[str, str] | None = None, - npm_locked_versions: dict[str, str] | None = None, + npm_locked_versions: Mapping[str, str] | None = None, *, max_packages: int | None = None, max_findings: int | None = None, @@ -4270,7 +4321,7 @@ def dependency_remaining_seconds() -> float: content, path, locked_versions.get(_python_lock_scope(path)), - npm_locked_versions, + npm_locked_versions.get(str(Path(path).parent)), max_packages=min(MAX_DEPENDENCY_PACKAGES_PER_FILE, remaining_packages), max_findings=min(MAX_DEPENDENCY_FINDINGS_PER_FILE, remaining_dependency_findings), timeout_seconds=shared_remaining, diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py index 9ee6b740a..ec9b6d9d6 100644 --- a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json + import pytest from skillspector.inspection_ledger import LedgerOutcome, LedgerReason @@ -455,3 +457,114 @@ def empty_query(packages, _ecosystem, **_kwargs): and event["reason_code"] is LedgerReason.OUTPUT_LIMIT for event in response["inspection_ledger"] ) + + +@pytest.mark.parametrize("lock_version", [1, 2, 3]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_npm_locks_do_not_resolve_other_projects(lock_version, reverse): + def lock(version): + field = ( + {"dependencies": {"node-forge": {"version": version}}} + if lock_version == 1 + else {"packages": {"node_modules/node-forge": {"version": version}}} + ) + return json.dumps({"lockfileVersion": lock_version, **field}) + + cache = { + "a/package-lock.json": lock("1.2.1"), + "b/package-lock.json": lock("1.4.2"), + "b/package.json": '{"dependencies": {"node-forge": "^1.4.2"}}', + "c/package.json": '{"dependencies": {"node-forge": "^1.4.2"}}', + "b/independent/package.json": '{"dependencies": {"node-forge": "^1.4.2"}}', + } + components = list(reversed(cache)) if reverse else list(cache) + resolved = supply_chain._collect_npm_locked_versions(cache, components) + assert resolved["a"]["node-forge"] == "1.2.1" + assert resolved["b"]["node-forge"] == "1.4.2" + assert not resolved.get("c") + assert not resolved.get("b/independent") + # Every installed version still participates in its own lockfile audit. + assert supply_chain._extract_packages_from_npm_lock(cache["a/package-lock.json"])[0][:2] == ( + "node-forge", + "1.2.1", + ) + + +def test_npm_workspace_uses_nearest_install_and_keeps_hoisted_dependencies(): + cache = { + "package-lock.json": json.dumps( + { + "lockfileVersion": 3, + "packages": { + "": {"workspaces": ["packages/*"]}, + "packages/a": {"name": "a", "version": "1.0.0"}, + "packages/b": {"name": "b", "version": "1.0.0"}, + "node_modules/node-forge": {"version": "1.4.2"}, + "node_modules/@scope/helper": {"version": "2.0.0"}, + "packages/a/node_modules/node-forge": {"version": "1.2.1"}, + "node_modules/unrelated/node_modules/node-forge": {"version": "0.9.0"}, + }, + } + ), + "packages/a/package.json": "{}", + "packages/b/package.json": "{}", + "packages/c/package.json": "{}", + } + resolved = supply_chain._collect_npm_locked_versions(cache, list(cache)) + assert resolved["."]["node-forge"] == "1.4.2" + assert dict(resolved["packages/a"]) == {"node-forge": "1.2.1", "@scope/helper": "2.0.0"} + assert dict(resolved["packages/b"]) == {"node-forge": "1.4.2", "@scope/helper": "2.0.0"} + assert not resolved.get("packages/c") + + +@pytest.mark.parametrize("own_lock", ["", "{", '{"packages": {}}']) +@pytest.mark.parametrize("reverse", [False, True]) +def test_npm_workspace_own_lock_prevents_ancestor_resolution(own_lock, reverse): + cache = { + "package-lock.json": json.dumps( + {"packages": {"packages/a": {}, "node_modules/ws": {"version": "8.20.0"}}} + ), + "packages/a/package.json": '{"dependencies": {"ws": "^8.20.0"}}', + "packages/a/package-lock.json": own_lock, + } + components = list(reversed(cache)) if reverse else list(cache) + resolved = supply_chain._collect_npm_locked_versions(cache, components) + assert not resolved.get("packages/a") + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_npm_shrinkwrap_takes_precedence_and_keeps_both_lock_audits(reverse): + cache = { + "package-lock.json": '{"packages": {"node_modules/ws": {"version": "8.19.0"}}}', + "npm-shrinkwrap.json": '{"packages": {"node_modules/ws": {"version": "8.20.0"}}}', + } + components = list(reversed(cache)) if reverse else list(cache) + resolved = supply_chain._collect_npm_locked_versions(cache, components) + assert resolved["."]["ws"] == "8.20.0" + assert supply_chain._extract_packages_from_npm_lock(cache["package-lock.json"])[0][:2] == ( + "ws", + "8.19.0", + ) + + +def test_npm_lock_collection_respects_file_and_time_bounds(): + cache = { + "a/package-lock.json": '{"packages": {"node_modules/ws": {"version": "8.19.0"}}}', + "b/package-lock.json": '{"packages": {"node_modules/ws": {"version": "8.20.0"}}}', + } + assert not supply_chain._collect_npm_locked_versions(cache, list(cache), max_files=0) + assert not supply_chain._collect_npm_locked_versions(cache, list(cache), timeout_seconds=0) + assert list(supply_chain._collect_npm_locked_versions(cache, list(cache), max_files=1)) == ["a"] + + +@pytest.mark.parametrize( + "location", ["../other", "/other", "folder/../../other", "folder\\..\\other"] +) +def test_npm_lock_paths_cannot_resolve_manifests_outside_the_project(location): + cache = { + "project/package-lock.json": json.dumps( + {"packages": {location: {}, "node_modules/ws": {"version": "8.20.0"}}} + ), + "other/package.json": "{}", + } + assert not supply_chain._collect_npm_locked_versions(cache, list(cache)).get("other") diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index 5086dde65..1d3a3c3e7 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -4195,7 +4195,7 @@ def test_manifest_range_resolves_to_the_direct_install(self) -> None: indent=2, ) cache = {"package-lock.json": content} - assert sc_mod._collect_npm_locked_versions(cache, list(cache)) == {"ws": "8.20.0"} + assert sc_mod._collect_npm_locked_versions(cache, list(cache))["."] == {"ws": "8.20.0"} def test_npm_normalization_keeps_underscores_distinct(self) -> None: # PyPI folds "_" into "-"; npm does not, and both string_decoder and string-decoder @@ -4234,7 +4234,7 @@ def test_npm_and_python_lock_maps_are_collected_separately(self) -> None: } components = list(cache) assert sc_mod._collect_locked_versions(cache, components) == {"semver": "3.0.2"} - assert sc_mod._collect_npm_locked_versions(cache, components) == {"semver": "7.6.0"} + assert sc_mod._collect_npm_locked_versions(cache, components)["."] == {"semver": "7.6.0"} def test_extract_packages_requirements_specifier_is_not_a_pin(self) -> None: # Regression: any specifier was treated as "==", so the floor "pillow>=10.0.0" was From 83265909510325c703461ac5426334766e2ba191 Mon Sep 17 00:00:00 2001 From: Christopher Kevin Date: Wed, 28 Oct 2026 13:19:39 -0700 Subject: [PATCH 2/4] fix(supply-chain): keep alias target versions out of slot inference Signed-off-by: Christopher Kevin --- .../analyzers/static_patterns_supply_chain.py | 7 +++++++ ...est_static_patterns_supply_chain_lockfiles.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index de0b5fdda..750ce0944 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -2249,6 +2249,13 @@ def _collect_npm_locked_versions( projects.add(relative) owner, separator, name = location.rpartition("node_modules/") version = entry.get("version") + registry_name = entry.get("name") + if ( + isinstance(registry_name, str) + and registry_name.strip() + and registry_name != name + ): + continue # Alias slots must not borrow their target package's version. if ( separator and (not owner or owner.endswith("/")) diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py index ec9b6d9d6..cb6cf8214 100644 --- a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -568,3 +568,19 @@ def test_npm_lock_paths_cannot_resolve_manifests_outside_the_project(location): "other/package.json": "{}", } assert not supply_chain._collect_npm_locked_versions(cache, list(cache)).get("other") + + +def test_npm_alias_install_does_not_pin_the_alias_registry_name(): + content = json.dumps( + {"packages": {"node_modules/lodash": {"name": "node-forge", "version": "1.4.2"}}} + ) + cache = {"package-lock.json": content} + resolved = supply_chain._collect_npm_locked_versions(cache, list(cache)) + manifest = '{"dependencies": {"lodash": "npm:node-forge@1.4.2"}}' + packages = supply_chain._apply_locked_versions( + supply_chain._extract_packages_from_package_json(manifest), + resolved["."], + supply_chain._normalize_npm_package_name, + ) + assert packages == [("lodash", None, 1)] + assert supply_chain._extract_packages_from_npm_lock(content) == [("node-forge", "1.4.2", 1)] From e2f541040a1b71de96a7778fcacf203eed2896a8 Mon Sep 17 00:00:00 2001 From: Christopher Kevin Date: Wed, 28 Oct 2026 13:30:13 -0700 Subject: [PATCH 3/4] fix(supply-chain): preserve legacy npm workspace lock resolution Signed-off-by: Christopher Kevin --- .../analyzers/static_patterns_supply_chain.py | 35 +++++++++--- ..._static_patterns_supply_chain_lockfiles.py | 56 +++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 750ce0944..15861b5af 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -2266,13 +2266,34 @@ def _collect_npm_locked_versions( _normalize_npm_package_name(name) ] = version.strip() elif isinstance(data.get("dependencies"), dict): - installs[PurePosixPath(".")] = { - _normalize_npm_package_name(name): entry["version"].strip() - for name, entry in data["dependencies"].items() - if isinstance(entry, dict) - and isinstance(entry.get("version"), str) - and entry["version"].strip() - } + trees = {PurePosixPath("."): data["dependencies"]} + for entry in data["dependencies"].values(): + version = entry.get("version") if isinstance(entry, dict) else None + if not isinstance(version, str) or not version.startswith("file:"): + continue + location = version[5:] + relative = PurePosixPath(location) + if ( + not location + or relative == PurePosixPath(".") + or relative.is_absolute() + or ".." in relative.parts + or "\\" in location + or str(root / relative) not in manifest_dirs + ): + continue + # v1 records workspace links here instead of in the packages table. + projects.add(relative) + trees[relative] = entry.get("dependencies", {}) + for project, dependencies in trees.items(): + if isinstance(dependencies, dict): + installs[project] = { + _normalize_npm_package_name(name): entry["version"].strip() + for name, entry in dependencies.items() + if isinstance(entry, dict) + and isinstance(entry.get("version"), str) + and entry["version"].strip() + } for project in projects: scope = str(root / project) depth = len(root.parts) diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py index cb6cf8214..88c794275 100644 --- a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -584,3 +584,59 @@ def test_npm_alias_install_does_not_pin_the_alias_registry_name(): ) assert packages == [("lodash", None, 1)] assert supply_chain._extract_packages_from_npm_lock(content) == [("node-forge", "1.4.2", 1)] + + +def test_npm_v1_workspace_uses_recorded_link_and_nearest_locked_install(): + cache = { + "package-lock.json": json.dumps( + { + "lockfileVersion": 1, + "dependencies": { + "workspace-a": { + "version": "file:packages/a", + "dependencies": {"node-forge": {"version": "1.2.1"}}, + }, + "workspace-b": {"version": "file:packages/b"}, + "node-forge": {"version": "1.4.2"}, + }, + } + ), + "packages/a/package.json": "{}", + "packages/b/package.json": "{}", + "packages/c/package.json": "{}", + } + resolved = supply_chain._collect_npm_locked_versions(cache, list(cache)) + assert resolved["."]["node-forge"] == "1.4.2" + assert resolved["packages/a"]["node-forge"] == "1.2.1" + assert resolved["packages/b"]["node-forge"] == "1.4.2" + assert not resolved.get("packages/c") + cache["packages/b/package-lock.json"] = "{" + assert not supply_chain._collect_npm_locked_versions(cache, list(cache)).get("packages/b") + + +@pytest.mark.parametrize( + "location", ["../other", "/other", "folder/../../other", "folder\\..\\other"] +) +def test_npm_v1_links_cannot_resolve_manifests_outside_the_project(location): + cache = { + "project/package-lock.json": json.dumps( + { + "dependencies": { + "linked": {"version": f"file:{location}"}, + "ws": {"version": "8.20.0"}, + } + } + ), + "other/package.json": "{}", + } + assert not supply_chain._collect_npm_locked_versions(cache, list(cache)).get("other") + + +def test_npm_v1_self_link_does_not_replace_the_root_install_tree(): + cache = { + "package-lock.json": json.dumps( + {"dependencies": {"self": {"version": "file:./"}, "ws": {"version": "8.20.0"}}} + ), + "package.json": "{}", + } + assert supply_chain._collect_npm_locked_versions(cache, list(cache))["."]["ws"] == "8.20.0" From 26d3b7ed5bf2b3ddba3bafbe2dcba3f607087d92 Mon Sep 17 00:00:00 2001 From: Christopher Kevin Date: Mon, 2 Nov 2026 11:26:26 -0800 Subject: [PATCH 4/4] fix(supply-chain): preserve scoped lock parse and filename contracts Signed-off-by: Christopher Kevin --- .../analyzers/static_patterns_supply_chain.py | 19 ++++--- ..._static_patterns_supply_chain_lockfiles.py | 56 ++++++++++++++++--- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 15861b5af..e0cac1a72 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -2203,8 +2203,16 @@ def _collect_npm_locked_versions( for path in components if _is_npm_lockfile(path) } - component_set = set(components) - manifest_dirs = {str(Path(path).parent) for path in components if path.endswith("package.json")} + shrinkwrap_dirs = { + str(Path(path).parent) + for path in components + if _dependency_filename(path) == "npm-shrinkwrap.json" + } + manifest_dirs = { + str(Path(path).parent) + for path in components + if _dependency_filename(path) == "package.json" + } started_at = time.monotonic() files_read = 0 for path in components: @@ -2215,10 +2223,7 @@ def _collect_npm_locked_versions( if timeout_seconds is not None and time.monotonic() - started_at >= timeout_seconds: break root = Path(path).parent - if ( - Path(path).name == "package-lock.json" - and str(root / "npm-shrinkwrap.json") in component_set - ): + if _dependency_filename(path) == "package-lock.json" and str(root) in shrinkwrap_dirs: continue # npm gives shrinkwrap precedence when both files exist. content = file_cache.get(path) if content is None: @@ -2226,7 +2231,7 @@ def _collect_npm_locked_versions( files_read += 1 try: data = json.loads(content) - except (ValueError, TypeError): + except (ValueError, TypeError, RecursionError): continue if not isinstance(data, dict): continue diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py index 88c794275..1f31ba5c3 100644 --- a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -490,7 +490,8 @@ def lock(version): ) -def test_npm_workspace_uses_nearest_install_and_keeps_hoisted_dependencies(): +@pytest.mark.parametrize("manifest_name", ["package.json", "PACKAGE.JSON"]) +def test_npm_workspace_uses_nearest_install_and_keeps_hoisted_dependencies(manifest_name): cache = { "package-lock.json": json.dumps( { @@ -506,9 +507,9 @@ def test_npm_workspace_uses_nearest_install_and_keeps_hoisted_dependencies(): }, } ), - "packages/a/package.json": "{}", - "packages/b/package.json": "{}", - "packages/c/package.json": "{}", + f"packages/a/{manifest_name}": "{}", + f"packages/b/{manifest_name}": "{}", + f"packages/c/{manifest_name}": "{}", } resolved = supply_chain._collect_npm_locked_versions(cache, list(cache)) assert resolved["."]["node-forge"] == "1.4.2" @@ -532,16 +533,55 @@ def test_npm_workspace_own_lock_prevents_ancestor_resolution(own_lock, reverse): assert not resolved.get("packages/a") +def test_deep_npm_own_lock_keeps_parse_error_without_borrowing_parent_pin(monkeypatch): + seen = [] + + def capture_packages(packages, ecosystem, **kwargs): + seen.extend(packages) + return [[] for _ in packages] + + monkeypatch.setattr(supply_chain, "query_batch", capture_packages) + cache = { + "package-lock.json": json.dumps( + {"packages": {"packages/a": {}, "node_modules/ws": {"version": "8.20.0"}}} + ), + "packages/a/package.json": '{"dependencies": {"ws": "^8.20.0"}}', + "packages/a/package-lock.json": "[" * 20_000 + "]" * 20_000, + } + response = supply_chain.node( + { + "components": list(cache), + "file_cache": cache, + "local_file_cache": cache, + "manifest": {}, + "component_metadata": [], + } + ) + + assert seen == [("ws", "8.20.0"), ("ws", None)] + assert any( + event["path"] == "packages/a/package-lock.json" + and event.get("reason_code") is LedgerReason.DEPENDENCY_PARSE_ERROR + and event.get("error_class") == "RecursionError" + for event in response["inspection_ledger"] + ) + + +@pytest.mark.parametrize( + "lock_names", + [("package-lock.json", "npm-shrinkwrap.json"), ("PACKAGE-LOCK.JSON", "NPM-SHRINKWRAP.JSON")], +) @pytest.mark.parametrize("reverse", [False, True]) -def test_npm_shrinkwrap_takes_precedence_and_keeps_both_lock_audits(reverse): +def test_npm_shrinkwrap_takes_precedence_and_keeps_both_lock_audits(lock_names, reverse): + package_lock, shrinkwrap = lock_names cache = { - "package-lock.json": '{"packages": {"node_modules/ws": {"version": "8.19.0"}}}', - "npm-shrinkwrap.json": '{"packages": {"node_modules/ws": {"version": "8.20.0"}}}', + package_lock: '{"packages": {"node_modules/ws": {"version": "8.19.0"}}}', + shrinkwrap: '{"packages": {"node_modules/ws": {"version": "8.20.0"}}}', } components = list(reversed(cache)) if reverse else list(cache) resolved = supply_chain._collect_npm_locked_versions(cache, components) assert resolved["."]["ws"] == "8.20.0" - assert supply_chain._extract_packages_from_npm_lock(cache["package-lock.json"])[0][:2] == ( + assert supply_chain._extract_packages_from_npm_lock(cache[package_lock])[0][:2] == ( "ws", "8.19.0", )