Skip to content

Commit 79771bf

Browse files
committed
feat(supply-chain): resolve npm dependencies through the lockfile
SC4 read package.json and stopped there. For npm that is the smaller half: the manifest lists direct dependencies, usually as a range, and the versions actually installed -- direct and transitive -- are in package-lock.json, which was never read. Python has no such gap; uv.lock and poetry.lock are already read and preferred. Read package-lock.json and npm-shrinkwrap.json the same way: resolve manifest ranges to the version on disk, and scan the lockfile itself so transitive dependencies are covered. Both layouts are handled -- lockfileVersion 2/3 keyed by install path, and version 1 with nested dependencies. Three details are load-bearing: - Deduplication is by name and version, not by name. npm installs the same package at several versions routinely, nesting the ones it cannot hoist; keeping one entry per name drops the others silently, and a dropped copy is as installed as the one kept. - The npm and PyPI version maps are separate. semver, packaging and requests all exist in both ecosystems, so one shared map would answer a Python question with an npm version. Name normalization differs for the same reason: PyPI folds _ into -, npm does not, and string_decoder and string-decoder are two real packages. - Line numbers come from one indexing pass. Searching per package is quadratic, which cost 6.3s on a 5000-entry lockfile; lockfiles that size are ordinary. Signed-off-by: Marco Macrì <Mark2Mac@users.noreply.github.com>
1 parent 082048b commit 79771bf

2 files changed

Lines changed: 374 additions & 6 deletions

File tree

src/skillspector/nodes/analyzers/static_patterns_supply_chain.py

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727

2828
from __future__ import annotations
2929

30+
import json
3031
import re
3132
import sys
3233
import tomllib
34+
from collections.abc import Callable
3335
from urllib.parse import urlparse
3436

3537
from packaging.requirements import InvalidRequirement, Requirement
@@ -627,6 +629,107 @@ def _is_python_lockfile(file_path: str) -> bool:
627629
return "uv.lock" in lower_path or "poetry.lock" in lower_path
628630

629631

632+
def _normalize_npm_package_name(name: str) -> str:
633+
"""Normalize an npm package name.
634+
635+
Deliberately *not* ``_normalize_package_name``: that one folds ``_`` into ``-`` for PyPI,
636+
where the two are the same project. On npm they are different packages — ``string_decoder``
637+
and ``string-decoder`` both exist — so folding them would resolve a dependency against
638+
another package's lockfile entry.
639+
"""
640+
return name.strip().lower()
641+
642+
643+
def _is_npm_lockfile(file_path: str) -> bool:
644+
lower_path = file_path.lower()
645+
return lower_path.endswith(("package-lock.json", "npm-shrinkwrap.json"))
646+
647+
648+
def _npm_lock_line_index(content: str) -> dict[str, int]:
649+
"""Map each JSON key to the first line it appears on, in a single pass.
650+
651+
The obvious implementation — search the file once per package — is quadratic, because both
652+
the search and the offset-to-line conversion restart from the top every time. On a 5000-entry
653+
lockfile that cost 6.3s, and lockfiles that size are ordinary. One pass costs ~20ms.
654+
"""
655+
index: dict[str, int] = {}
656+
line = 1
657+
pos = 0
658+
for match in re.finditer(r'"((?:[^"\\]|\\.)*)"\s*:', content):
659+
line += content.count("\n", pos, match.start())
660+
pos = match.start()
661+
index.setdefault(match.group(1), line)
662+
return index
663+
664+
665+
def _npm_lock_entries(content: str) -> list[tuple[str, str, int, int]]:
666+
"""Extract (name, version, line, depth) for every install recorded in an npm lockfile.
667+
668+
Covers both layouts: ``lockfileVersion`` 2/3 keep every install under ``packages`` keyed by
669+
install path, while version 1 nests ``dependencies``. The root entry (empty key) is the
670+
project itself, not a dependency, and is skipped.
671+
672+
*depth* is 0 for a top-level install and grows with nesting, which is what tells a direct
673+
dependency from a transitive one — the manifest's ``^1.2.3`` resolves to the top-level copy.
674+
675+
Deduplication is by name *and* version, never by name alone: npm installs the same package
676+
at several versions routinely, nesting the odd ones out under their dependents. Keeping one
677+
entry per name would drop the others silently, and the dropped copy is as installed — and as
678+
exploitable — as the one that happened to be seen first.
679+
"""
680+
try:
681+
data = json.loads(content)
682+
except (ValueError, TypeError):
683+
return []
684+
if not isinstance(data, dict):
685+
return []
686+
687+
line_index = _npm_lock_line_index(content)
688+
entries: list[tuple[str, str, int, int]] = []
689+
seen: set[tuple[str, str]] = set()
690+
691+
def add(name: object, version: object, key: str, depth: int) -> None:
692+
if not isinstance(name, str) or not name.strip():
693+
return
694+
if not isinstance(version, str) or not version.strip():
695+
return
696+
name, version = name.strip(), version.strip()
697+
identity = (_normalize_npm_package_name(name), version)
698+
if identity in seen:
699+
return
700+
seen.add(identity)
701+
entries.append((name, version, line_index.get(key, 1), depth))
702+
703+
packages = data.get("packages")
704+
if isinstance(packages, dict):
705+
for path, entry in packages.items():
706+
if not path or not isinstance(entry, dict):
707+
continue
708+
# "node_modules/a/node_modules/b" is package b installed under a: the name is the
709+
# last segment, and "name" is only present for aliased installs.
710+
name = entry.get("name")
711+
if not isinstance(name, str) or not name.strip():
712+
name = path.split("node_modules/")[-1]
713+
add(name, entry.get("version"), path, path.count("node_modules/") - 1)
714+
715+
def walk(tree: object, depth: int) -> None:
716+
if not isinstance(tree, dict):
717+
return
718+
for name, entry in tree.items():
719+
if not isinstance(entry, dict):
720+
continue
721+
add(name, entry.get("version"), name, depth)
722+
walk(entry.get("dependencies"), depth + 1)
723+
724+
walk(data.get("dependencies"), 0)
725+
return entries
726+
727+
728+
def _extract_packages_from_npm_lock(content: str) -> list[tuple[str, str | None, int]]:
729+
"""Extract exact package versions from an npm lockfile."""
730+
return [(name, version, line) for name, version, line, _depth in _npm_lock_entries(content)]
731+
732+
630733
def _extract_packages_from_toml_lock(content: str) -> list[tuple[str, str | None, int]]:
631734
"""Extract exact package versions from TOML lockfiles such as uv.lock and poetry.lock."""
632735
try:
@@ -656,13 +759,14 @@ def _extract_packages_from_toml_lock(content: str) -> list[tuple[str, str | None
656759
def _apply_locked_versions(
657760
packages: list[tuple[str, str | None, int]],
658761
locked_versions: dict[str, str] | None,
762+
normalize: Callable[[str], str] = _normalize_package_name,
659763
) -> list[tuple[str, str | None, int]]:
660764
"""Prefer lockfile versions for manifest dependencies without exact versions."""
661765
if not locked_versions:
662766
return packages
663767
resolved: list[tuple[str, str | None, int]] = []
664768
for name, version, line_num in packages:
665-
locked_version = locked_versions.get(_normalize_package_name(name))
769+
locked_version = locked_versions.get(normalize(name))
666770
resolved.append((name, version or locked_version, line_num))
667771
return resolved
668772

@@ -685,6 +789,35 @@ def _collect_locked_versions(
685789
return locked_versions
686790

687791

792+
def _collect_npm_locked_versions(
793+
file_cache: dict[str, str],
794+
components: list[str],
795+
) -> dict[str, str]:
796+
"""Build package -> exact version map from npm lockfiles in the project.
797+
798+
Kept separate from the Python map: the two ecosystems share package names (``semver``,
799+
``packaging``, ``requests`` all exist in both), so a single map would resolve a PyPI
800+
dependency against an npm version, and vice versa.
801+
"""
802+
locked_versions: dict[str, str] = {}
803+
depths: dict[str, int] = {}
804+
for path in components:
805+
if not _is_npm_lockfile(path):
806+
continue
807+
content = file_cache.get(path)
808+
if not content:
809+
continue
810+
for name, version, _line_num, depth in _npm_lock_entries(content):
811+
key = _normalize_npm_package_name(name)
812+
# A manifest range names a *direct* dependency, so it resolves to the top-level
813+
# install. Nested copies exist for other packages' constraints; answering with one
814+
# would report a version this manifest never asked for.
815+
if key not in depths or depth < depths[key]:
816+
locked_versions[key] = version
817+
depths[key] = depth
818+
return locked_versions
819+
820+
688821
def _version_lt(v1: str, v2: str) -> bool:
689822
"""Simple version comparison: True if v1 < v2 (numeric tuple comparison)."""
690823

@@ -987,18 +1120,20 @@ def _analyze_dependencies(
9871120
content: str,
9881121
file_path: str,
9891122
locked_versions: dict[str, str] | None = None,
1123+
npm_locked_versions: dict[str, str] | None = None,
9901124
) -> list[AnalyzerFinding]:
9911125
"""Run SC4/SC5/SC6 checks on dependency files."""
9921126
findings: list[AnalyzerFinding] = []
9931127
tag = [PatternCategory.SUPPLY_CHAIN.value]
9941128

9951129
lower_path = file_path.lower()
9961130
is_lockfile = _is_python_lockfile(lower_path)
1131+
is_npm_lock = _is_npm_lockfile(lower_path)
9971132
is_python_dep = (
9981133
any(n in lower_path for n in ["requirements", "pyproject.toml", "setup.py", "pipfile"])
9991134
or is_lockfile
10001135
)
1001-
is_npm_dep = "package.json" in lower_path
1136+
is_npm_dep = "package.json" in lower_path or is_npm_lock
10021137

10031138
if not is_python_dep and not is_npm_dep:
10041139
return findings
@@ -1016,7 +1151,14 @@ def _analyze_dependencies(
10161151
fallback_db = _FALLBACK_VULNERABLE_PYPI
10171152
popular = _POPULAR_PYPI
10181153
else:
1019-
packages = _extract_packages_from_package_json(content)
1154+
if is_npm_lock:
1155+
packages = _extract_packages_from_npm_lock(content)
1156+
else:
1157+
packages = _apply_locked_versions(
1158+
_extract_packages_from_package_json(content),
1159+
npm_locked_versions,
1160+
_normalize_npm_package_name,
1161+
)
10201162
ecosystem = ECOSYSTEM_NPM
10211163
fallback_db = _FALLBACK_VULNERABLE_NPM
10221164
popular = _POPULAR_NPM
@@ -1223,13 +1365,16 @@ def record_extra_findings(
12231365
components: list[str] = state.get("components") or []
12241366
file_cache: dict[str, str] = state.get("file_cache") or {}
12251367
locked_versions = _collect_locked_versions(file_cache, components)
1368+
npm_locked_versions = _collect_npm_locked_versions(file_cache, components)
12261369
for path in components:
12271370
lower_path = path.lower()
12281371
is_dep_file = any(
12291372
n in lower_path
12301373
for n in [
12311374
"requirements",
12321375
"package.json",
1376+
"package-lock.json",
1377+
"npm-shrinkwrap.json",
12331378
"pyproject.toml",
12341379
"setup.py",
12351380
"pipfile",
@@ -1242,7 +1387,7 @@ def record_extra_findings(
12421387
content = file_cache.get(path)
12431388
if not content:
12441389
continue
1245-
dep_findings = _analyze_dependencies(content, path, locked_versions)
1390+
dep_findings = _analyze_dependencies(content, path, locked_versions, npm_locked_versions)
12461391
dependency_findings = [analyzer_finding_to_finding(af) for af in dep_findings]
12471392
findings.extend(dependency_findings)
12481393
record_extra_findings(

0 commit comments

Comments
 (0)