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__":