From b5b34b902f9e652997112905f77f062293fd8d28 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Mon, 19 Oct 2026 02:13:35 +0800 Subject: [PATCH] Enforce npm audit coverage and compatible security repairs Explicitly disable force during npm security repairs and include production, development, optional and peer dependencies during audits and repairs. Inherited project, user and environment settings can no longer authorize breaking security upgrades or hide development findings. Add offline command regressions and real npm 11.9.0 CI fixtures that repair vulnerable minimist while retaining the incompatible lodash major version. Document the command policy and project validation boundary. --- node/maintenance.md | 6 + node/runtime/templates/dependency_audit.py | 11 +- node/tests/test_dependency_audit.py | 39 ++++++- node/tests/verify_node_project.py | 122 ++++++++++++++++++++- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/node/maintenance.md b/node/maintenance.md index fb6826a..ba5d739 100644 --- a/node/maintenance.md +++ b/node/maintenance.md @@ -26,6 +26,12 @@ retain changes only when before/after audit evidence confirms a security fix; unrelated changes are discarded. The engine does not force incompatible upgrades or remove project overrides automatically. +For npm, audits include production, development, optional, and peer dependencies, +even when `.npmrc`, user configuration, or the environment omits a dependency +type. Security repairs explicitly disable force mode, including inherited +`force=true` settings. These overrides apply to audit and repair commands; +project validation still uses the project's install settings. + Upgrade audits attempt security fixes first, then routine stable upgrades. `maintenance.upgradeScope` limits routine **direct** upgrades to `patch`, `minor` (including patches), or `major` (the default, including both). Restricted scopes diff --git a/node/runtime/templates/dependency_audit.py b/node/runtime/templates/dependency_audit.py index 0844ac8..5166da9 100644 --- a/node/runtime/templates/dependency_audit.py +++ b/node/runtime/templates/dependency_audit.py @@ -15,6 +15,12 @@ SEVERITY_ORDER = {"critical": 0, "high": 1, "moderate": 2, "low": 3, "info": 4} DIRECTORY_PATTERN = re.compile(r"[A-Za-z0-9_./ -]+") +NPM_AUDIT_INCLUDES = ( + "--include=prod", + "--include=dev", + "--include=optional", + "--include=peer", +) def npm_shrinkwrap_path(directory: Path) -> Path: @@ -577,7 +583,8 @@ def audit(directory: Path, *, deadline: float | None = None) -> dict | str: ) flags = ["--audit-level", "info"] else: - flags = ["--audit", "--package-lock-only"] + # CLI includes override project, user and environment omission settings. + flags = ["--audit", "--package-lock-only", *NPM_AUDIT_INCLUDES] result = run( directory, "audit", @@ -837,6 +844,8 @@ def fix_security( "--package-lock-only", "--ignore-scripts", "--audit", + "--no-force", + *NPM_AUDIT_INCLUDES, *(["--before", release_before] if release_before else []), deadline=deadline, ) diff --git a/node/tests/test_dependency_audit.py b/node/tests/test_dependency_audit.py index 60b31ce..d849742 100644 --- a/node/tests/test_dependency_audit.py +++ b/node/tests/test_dependency_audit.py @@ -341,6 +341,32 @@ def test_audit_error_is_not_reported_as_zero_vulnerabilities(npm_project, monkey assert audit(npm_project) == "Audit unavailable (exit 1)." +def test_npm_audit_explicitly_covers_every_dependency_type(npm_project, monkeypatch): + commands = fake_npm(monkeypatch) + audit(npm_project) + assert { + "--include=prod", + "--include=dev", + "--include=optional", + "--include=peer", + } <= set(commands[0]) + + +def test_npm_security_repair_disables_force_and_covers_every_dependency_type( + npm_project, monkeypatch +): + commands = fake_npm(monkeypatch) + audit_module["fix_security"](npm_project, float("inf")) + assert commands[0][:2] == ["audit", "fix"] + assert { + "--no-force", + "--include=prod", + "--include=dev", + "--include=optional", + "--include=peer", + } <= set(commands[0]) + + def test_breaking_fix_candidate_is_flagged_for_manual_review(npm_project, monkeypatch): data = { "metadata": {"vulnerabilities": {"high": 1, "total": 1}}, @@ -411,7 +437,18 @@ def test_shared_time_budget_stops_commands_and_writes_partial_report( clock = iter([0, 0, 2700]) monkeypatch.setattr(audit_module["time"], "monotonic", lambda: next(clock, 2700)) report = prepare(npm_project) - assert commands == [["audit", "--json", "--audit", "--package-lock-only"]] + assert commands == [ + [ + "audit", + "--json", + "--audit", + "--package-lock-only", + "--include=prod", + "--include=dev", + "--include=optional", + "--include=peer", + ] + ] assert "Could not look up renderer" in report assert "not run: npm frozen install failed (exit 124)" in report assert "**Security:** Audit unavailable (exit 124)." in report diff --git a/node/tests/verify_node_project.py b/node/tests/verify_node_project.py index 2471fee..fd57994 100644 --- a/node/tests/verify_node_project.py +++ b/node/tests/verify_node_project.py @@ -2,14 +2,131 @@ import argparse import json +import os import runpy +import shutil import subprocess import tempfile from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).parents[1] +def check_npm_policy(version): + """Prove real security repair and complete coverage under inherited config.""" + with tempfile.TemporaryDirectory(prefix="npm-policy-") as temporary: + root = Path(temporary) + user_config = root / "user.npmrc" + global_config = root / "global.npmrc" + user_config.write_text("") + global_config.write_text("") + environment = { + key: value + for key, value in os.environ.items() + if not key.lower().startswith("npm_config_") and key != "NODE_ENV" + } + environment.update( + npm_config_userconfig=str(user_config), + npm_config_globalconfig=str(global_config), + npm_config_cache=str(root / "cache"), + ) + with patch.dict(os.environ, environment, clear=True): + baseline = root / "baseline" + baseline.mkdir() + manifest = { + "name": "npm-policy-integration-fixture", + "version": "1.0.0", + "private": True, + "packageManager": f"npm@{version}", + "dependencies": {"lodash": "^3.10.1"}, + "devDependencies": {"minimist": "^1.2.5"}, + "scripts": { + "check": "node -e \"require('node:assert/strict').equal(" + "require('lodash').map([1], x => x + 1)[0], 2)\"" + }, + } + (baseline / "package.json").write_text(json.dumps(manifest)) + # Resolve real vulnerable versions without depending on registry latest. + subprocess.run( + [ + "npm", + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-force", + "--before=2021-03-01", + ], + cwd=baseline, + check=True, + timeout=180, + ) + engine = runpy.run_path(str(ROOT / "runtime/templates/dependency_audit.py")) + policy = "force=true\nomit[]=dev\nomit[]=optional\nomit[]=peer\n" + for source in ("project", "user", "environment"): + project = root / source + shutil.copytree(baseline, project) + user_config.write_text(policy if source == "user" else "") + (project / ".npmrc").write_text(policy if source == "project" else "") + inherited = {"NODE_ENV": "production"} + if source == "environment": + inherited.update(npm_config_force="true", npm_config_omit="dev") + with patch.dict(os.environ, inherited): + before_manifest = (project / "package.json").read_bytes() + before_lock = json.loads( + (project / "package-lock.json").read_text() + ) + assert ( + before_lock["packages"]["node_modules/minimist"]["version"] + == "1.2.5" + ) + assert ( + before_lock["packages"]["node_modules/lodash"]["version"] + == "3.10.1" + ) + inherited_audit = subprocess.run( + ["npm", "audit", "--json", "--audit", "--package-lock-only"], + cwd=project, + capture_output=True, + text=True, + timeout=180, + ) + filtered = json.loads(inherited_audit.stdout) + assert "lodash" in filtered["vulnerabilities"], filtered + assert "minimist" not in filtered["vulnerabilities"], filtered + before = engine["audit"](project) + assert isinstance(before, dict), before + assert {"minimist", "lodash"} <= before["vulnerabilities"].keys(), ( + before + ) + validation = {} + report = engine["prepare"]( + project, + validation=validation, + validation_script="check", + maintenance_mode="security-only", + ) + assert validation["passed"], report + assert (project / "package.json").read_bytes() == before_manifest, ( + report + ) + after_lock = json.loads((project / "package-lock.json").read_text()) + assert ( + after_lock["packages"]["node_modules/lodash"]["version"] + == "3.10.1" + ), report + assert ( + after_lock["packages"]["node_modules/minimist"]["version"] + != "1.2.5" + ), report + after = engine["audit"](project) + assert isinstance(after, dict), after + assert "minimist" not in after["vulnerabilities"], after + assert "lodash" in after["vulnerabilities"], after + return "passed (project, user and environment configuration)" + + def check(node, manager, version): with tempfile.TemporaryDirectory(prefix="node-maintenance-") as temporary: root = Path(temporary) @@ -81,7 +198,7 @@ def check(node, manager, version): ] == "7.0.0" ), report - return { + result = { "node": subprocess.check_output(["node", "--version"], text=True).strip(), "manager": manager, "version": selected[manager], @@ -89,6 +206,9 @@ def check(node, manager, version): "security_and_upgrades": "passed", "frozen_install_and_tests": "passed", } + if manager == "npm": + result["inherited_npm_security_policy"] = check_npm_policy(version) + return result if __name__ == "__main__":