Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions node/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion node/runtime/templates/dependency_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
)
Expand Down
39 changes: 38 additions & 1 deletion node/tests/test_dependency_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
Expand Down Expand Up @@ -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
Expand Down
122 changes: 121 additions & 1 deletion node/tests/verify_node_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -81,14 +198,17 @@ 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],
"standalone_library": "passed",
"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__":
Expand Down
Loading