""" NPM ecosystem handler. Supports: - package.json (manifest) - package-lock.json, yarn.lock, pnpm-lock.yaml (lockfiles) """ from __future__ import annotations import json import re from pathlib import Path import semantic_version import yaml from pyarn.lockfile import Lockfile as YarnLockfile from vuln_scan.core.models import ( Dependency, Ecosystem, EcosystemParseResult, SecurityVulnerability, ) from vuln_scan.ecosystems.base import EcosystemHandler class NodeEcosystemHandler(EcosystemHandler): """Handler for the NPM / Node.js ecosystem.""" id = "node" ecosystem = Ecosystem.NPM # ------------------------------------------------------- # File discovery # ------------------------------------------------------- @property def lockfile_names(self) -> set[str]: return {"package-lock.json", "yarn.lock", "pnpm-lock.yaml"} @property def manifest_names(self) -> set[str]: return {"package.json"} @property def manifest_globs(self) -> set[str]: return set() # ------------------------------------------------------- # Parsing — dispatch # ------------------------------------------------------- def parse_manifest( self, manifest_path: str, full_path: Path, ) -> EcosystemParseResult: content = full_path.read_text(encoding="utf-8") return self.parse_manifest_content(manifest_path, content) def parse_manifest_content( self, manifest_path: str, content: str, ) -> EcosystemParseResult: filename = Path(manifest_path).name is_lockfile = self.is_lockfile(filename) if filename == "package-lock.json": deps = self._parse_package_lock_content(content, manifest_path) elif filename == "yarn.lock": deps = self._parse_yarn_lock_content(content, manifest_path) elif filename == "pnpm-lock.yaml": deps = self._parse_pnpm_lock_content(content, manifest_path) elif filename == "package.json": deps = self._parse_package_json_content(content, manifest_path) else: deps = [] return EcosystemParseResult( dependencies=deps, manifest_type="lockfile" if is_lockfile else "manifest", parser_name="npm", ) # ------------------------------------------------------- # Normalization # ------------------------------------------------------- def _build_spec(self, range_str: str) -> semantic_version.NpmSpec: return semantic_version.NpmSpec(range_str) def _normalize_vuln_range(self, spec: str) -> str: spec = (spec or "").strip() if not spec: return "" parts = [p.strip() for p in spec.split("||")] normalized_parts = [] for part in parts: part = part.replace(",", " ") part = " ".join(part.split()) part = re.sub(r"(<=|>=|<|>|=|~|\^)\s+", r"\1", part) tokens = part.split(" ") cleaned = [t.strip(",") for t in tokens if t.strip(",")] normalized_parts.append(" ".join(cleaned)) return " || ".join(normalized_parts) # ------------------------------------------------------- # Vulnerability matching # ------------------------------------------------------- def is_vulnerable( self, dependency: Dependency, alert: SecurityVulnerability, ) -> tuple[bool, str]: vuln_range = alert.vulnerable_range if not vuln_range: return True, "low" try: normalized = self._normalize_vuln_range(vuln_range) if not normalized: return True, "low" if dependency.version: try: dep_version = semantic_version.Version.coerce(dependency.version) except ValueError: return True, "low" is_vuln = self._version_in_range(dep_version, normalized) confidence = "high" if dependency.is_lockfile else "medium" return is_vuln, confidence if dependency.requirement: overlap = self._ranges_overlap( self._normalize_vuln_range(dependency.requirement), normalized ) return overlap, "low" return True, "low" except Exception: return True, "low" # ------------------------------------------------------- # package.json # ------------------------------------------------------- def _parse_package_json_content( self, content: str, manifest_path: str, ) -> list[Dependency]: data = self._safe_json_load(content) if data is None: return [] deps: list[Dependency] = [] for section in ( "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", ): section_data = data.get(section) if not isinstance(section_data, dict): continue for name, version_spec in section_data.items(): if not isinstance(version_spec, str): continue version, requirement = self._normalize_npm_specifier(version_spec) deps.append( Dependency( ecosystem=Ecosystem.NPM, name=name, version=version, requirement=requirement, manifest_path=manifest_path, is_lockfile=False, ) ) return deps # ------------------------------------------------------- # package-lock.json # ------------------------------------------------------- def _parse_package_lock_content( self, content: str, manifest_path: str, ) -> list[Dependency]: data = self._safe_json_load(content) if data is None: return [] seen: set[tuple[str, str]] = set() # npm v7+ uses "packages" with path-based keys packages = data.get("packages") if isinstance(packages, dict): return self._collect_v7_packages(packages, manifest_path, seen) # npm v1/v2 uses "dependencies" with nested objects deps: list[Dependency] = [] dependencies = data.get("dependencies") if isinstance(dependencies, dict): self._collect_lock_dependencies(dependencies, manifest_path, deps, seen) return deps def _collect_v7_packages( self, packages: dict[str, object], manifest_path: str, seen: set[tuple[str, str]], ) -> list[Dependency]: deps: list[Dependency] = [] for pkg_path, pkg_info in packages.items(): if not isinstance(pkg_info, dict) or pkg_path == "": continue name = pkg_info.get("name") or self._name_from_node_modules_path(pkg_path) if not name: continue version = pkg_info.get("version") if not isinstance(version, str) or not version: continue if (name, version) in seen: continue seen.add((name, version)) deps.append( Dependency( ecosystem=Ecosystem.NPM, name=name, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) return deps def _collect_lock_dependencies( self, dependencies: dict[str, object], manifest_path: str, deps: list[Dependency], seen: set[tuple[str, str]], ) -> None: for name, info in dependencies.items(): if not isinstance(info, dict): continue version = info.get("version") if isinstance(version, str) and version and (name, version) not in seen: seen.add((name, version)) deps.append( Dependency( ecosystem=Ecosystem.NPM, name=name, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) nested = info.get("dependencies") if isinstance(nested, dict): self._collect_lock_dependencies(nested, manifest_path, deps, seen) @staticmethod def _name_from_node_modules_path(pkg_path: str) -> str | None: """Extract package name from node_modules path.""" parts = pkg_path.split("node_modules/") last = parts[-1] return last if last else None # ------------------------------------------------------- # yarn.lock (using pyarn) # ------------------------------------------------------- def _parse_yarn_lock_content( self, content: str, manifest_path: str, ) -> list[Dependency]: try: lockfile = YarnLockfile.from_str(content) except Exception: return [] deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() for entry in lockfile.packages(): name = entry.name version = entry.version if not name or not version: continue if (name, version) not in seen: seen.add((name, version)) deps.append( Dependency( ecosystem=Ecosystem.NPM, name=name, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) return deps # ------------------------------------------------------- # pnpm-lock.yaml (using PyYAML) # ------------------------------------------------------- def _parse_pnpm_lock_content( self, content: str, manifest_path: str, ) -> list[Dependency]: try: data = yaml.safe_load(content) except yaml.YAMLError: return [] if not isinstance(data, dict): return [] deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() packages = data.get("packages") if isinstance(packages, dict): for pkg_key in packages: name, version = self._parse_pnpm_package_key(pkg_key) if name and version and (name, version) not in seen: seen.add((name, version)) deps.append( Dependency( ecosystem=Ecosystem.NPM, name=name, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) return deps @staticmethod def _parse_pnpm_package_key(key: str) -> tuple[str | None, str | None]: """ Parse pnpm lockfile package key into (name, version). Supports both v5 (leading /) and v6+ (no leading /) formats: /pkg-name@1.2.3 → ("pkg-name", "1.2.3") # v5 /@scope/pkg-name@1.2.3 → ("@scope/pkg-name", "1.2.3") # v5 scoped /pkg-name/1.2.3 → ("pkg-name", "1.2.3") # v5 alt pkg-name@1.2.3 → ("pkg-name", "1.2.3") # v6+ @scope/pkg-name@1.2.3 → ("@scope/pkg-name", "1.2.3") # v6+ scoped """ if not key: return None, None # Strip leading "/" if present (v5 format) stripped = key.lstrip("/") if not stripped: return None, None # Scoped package: @scope/pkg-name@version if stripped.startswith("@"): # Find the last "@" which separates name from version last_at = stripped.rfind("@") if last_at > 0: return stripped[:last_at], stripped[last_at + 1 :] return None, None # Unscoped: pkg-name@version if "@" in stripped: name, version = stripped.rsplit("@", 1) return (name, version) if name and version else (None, None) # v5 alternate format: pkg-name/version (no @) if "/" in stripped: name, version = stripped.rsplit("/", 1) return (name, version) if name and version else (None, None) return None, None # ------------------------------------------------------- # Helpers # ------------------------------------------------------- @staticmethod def _safe_json_load(content: str) -> dict[str, object] | None: try: data = json.loads(content) except ValueError: return None return data if isinstance(data, dict) else None @staticmethod def _normalize_npm_specifier(spec: str) -> tuple[str | None, str | None]: """ Normalize an npm version specifier. Returns: (exact_version, requirement_range) One will be populated, the other None. """ spec = spec.strip() if not spec: return None, None if re.fullmatch(r"\d+(\.\d+)*", spec): return spec, None if any( spec.startswith(p) for p in ("http:", "https:", "git:", "git+", "file:", "workspace:") ): return None, None if spec.startswith("npm:"): return None, spec return None, spec