diff --git a/scripts/hatch_build.py b/scripts/hatch_build.py index 626ab52a..49fd648f 100644 --- a/scripts/hatch_build.py +++ b/scripts/hatch_build.py @@ -138,7 +138,19 @@ def _bundle_ui(repo_root: Path, log: Callable[[str], None] = print) -> None: log("no prebuilt dist found; running npm build") try: subprocess.check_call( - [npm, "ci", "--prefer-offline", "--no-audit", "--no-fund"], + # --ignore-scripts: do NOT run npm lifecycle scripts from the + # (transitive) dependency tree at wheel-build / `git+` install time. + # Without it, a compromised dependency's postinstall would execute + # arbitrary code on the user's machine during install (GHSA-pfvh). + # Verified non-breaking: `tsc -b && vite build` needs no dependency + # install-scripts -- vite 8 (rolldown) ships prebuilt platform + # binaries rather than fetching them via a postinstall. + # NOTE: this also suppresses any *root* web/frontend/package.json + # prepare/postinstall script (there is none today). If one is ever + # added AND required for the build, invoke it explicitly here rather + # than dropping --ignore-scripts, which would re-open the dependency + # lifecycle-script RCE surface. + [npm, "ci", "--prefer-offline", "--no-audit", "--no-fund", "--ignore-scripts"], cwd=frontend_dir, ) subprocess.check_call([npm, "run", "build"], cwd=frontend_dir) diff --git a/tests/test_build_hook.py b/tests/test_build_hook.py index c83a17b2..2cdcb889 100644 --- a/tests/test_build_hook.py +++ b/tests/test_build_hook.py @@ -187,8 +187,10 @@ def test_calledprocesserror_is_caught(self, tmp_path: Path) -> None: assert _target(root).is_dir() def test_passes_resolved_npm_path_not_bare_name(self, tmp_path: Path) -> None: - """Root-cause fix: the resolved ``shutil.which`` path (``npm.cmd`` on - Windows) is handed to subprocess, never the bare string ``"npm"``.""" + """Two regressions guarded here: (1) the resolved ``shutil.which`` path + (``npm.cmd`` on Windows) is handed to subprocess, never the bare string + ``"npm"``; (2) ``npm ci`` carries ``--ignore-scripts`` (GHSA-pfvh) while + ``npm run build`` intentionally does NOT.""" root = _make_repo(tmp_path) resolved = "C:\\Program Files\\nodejs\\npm.cmd" with ( @@ -203,7 +205,15 @@ def test_passes_resolved_npm_path_not_bare_name(self, tmp_path: Path) -> None: assert ci_cmd[0] == resolved assert build_cmd[0] == resolved assert ci_cmd[1] == "ci" + # GHSA-pfvh: dependency lifecycle scripts must NOT run at wheel-build / + # `git+` install time (install-time RCE surface). Verified non-breaking + # against the real `tsc -b && vite build`. + assert "--ignore-scripts" in ci_cmd + # Intentional asymmetry: `--ignore-scripts` gates the dependency INSTALL + # (`npm ci`). `npm run build` runs our OWN build script and must NOT + # carry the flag -- do not "fix" the asymmetry by adding it there. assert build_cmd[1:] == ["run", "build"] + assert "--ignore-scripts" not in build_cmd def test_successful_npm_build_is_bundled(self, tmp_path: Path) -> None: """When npm produces dist/index.html, it gets copied into _ui_dist/."""