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
14 changes: 13 additions & 1 deletion scripts/hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions tests/test_build_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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/."""
Expand Down