2727
2828from __future__ import annotations
2929
30+ import json
3031import re
3132import sys
3233import tomllib
34+ from collections .abc import Callable
3335from urllib .parse import urlparse
3436
3537from 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+
630733def _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
656759def _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+
688821def _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