ci: pin every Studio install path to its lockfile#113
Conversation
build.sh and studio/setup.sh both call naked `bun install` and `npm install`. With caret ranges in package.json (the default for most deps), those commands resolve a fresh minor/patch from the registry if one exists, even though the lockfile pins specific versions. An attacker who hijacks any transitive dep and publishes a malicious patch release can have it pulled into the release build or end-user install without anyone noticing. Both paths now use lockfile-strict mode: bun install -> bun install --frozen-lockfile npm install -> npm ci These install exactly what the committed lockfile pins, verify cryptographic hashes, and fail fast on any drift between package.json and the lockfile. The CI workflows that build the frontend already use `npm ci`; this aligns the local build and end-user setup paths with the same guarantee. Verified `npm ci --no-fund --no-audit --dry-run` exits 0 against the current studio/frontend lockfile (1042 packages, no drift).
Followup to 152fe8d. Three more sites still called naked `bun install` / `npm install`, which honour caret ranges in package.json and can pull a fresh minor/patch of a transitive dep from the registry on the next run. studio/setup.ps1 (4 sites): the Windows end-user installer. bun install -> bun install --frozen-lockfile (both initial and the cache-clear retry); the npm fallback and the OXC validator npm install both -> npm ci. Error messages updated to reference the new command. studio/setup.sh: the OXC validator runtime install for the Unix path was still naked `npm install`. Now `npm ci`. github/workflows/release-desktop.yml: the desktop release build's frontend install was still naked `npm install`. Now `npm ci` so a published .app/.dmg/.AppImage/.msi can never have shipped with a registry-resolved transitive that drifted from the committed lockfile. The pinned Tauri CLI install in the same workflow stays as `npm install --save-dev @tauri-apps/cli@2.10.1` because that line is intentionally adding a specific package to package.json, not syncing from the lockfile. Verified `npm ci --no-fund --no-audit --dry-run` exits 0 against both the studio/frontend and studio/backend/core/data_recipe/ oxc-validator lockfiles.
Followup to 7bb1eb6 (npm install -> npm ci for the oxc-validator runtime install in studio/setup.sh, studio/setup.ps1). That commit worked locally because the lockfile already existed there from an earlier `npm install`, but `npm ci` failed in CI because the lockfile was never committed: npm error EUSAGE npm error The `npm ci` command can only install with an existing npm error package-lock.json or npm-shrinkwrap.json with lockfileVersion >= 1. Root cause: the project-root .gitignore has a bare `package-lock.json` entry left over from a Python-template gitignore. The frontend lockfile was force-added past it; the oxc-validator lockfile never was. So a fresh actions/checkout did not have it. Fix: Force-commit studio/backend/core/data_recipe/oxc-validator/package-lock.json (5 packages, lockfileVersion 3, integrity-pinned). Replace the bare gitignore rule with explicit `!` exceptions for the two committed npm-project lockfiles, with a comment explaining why stray lockfiles in random Python subtrees are still ignored. The pyproject.toml package-data glob `backend/core/data_recipe/oxc-validator/*.json` already pulls the lockfile into the pip-installed wheel; the only gap was that fresh git checkouts (CI) didn't have it.
Collapse the worked-example narrative to one-line WHYs. Code is unchanged.
Two issues codex flagged: 1. bun.lock gate (studio/frontend/.gitignore line 14 ignores bun.lock, so it is never committed). bun install --frozen-lockfile cannot migrate from package-lock.json, so without a bun.lock the bun path always fails. setup.sh then misclassifies that as a corrupt cache, clears the user's bun cache, and re-runs the same guaranteed-failing command before falling back to npm. build.sh, studio/setup.sh, and studio/setup.ps1 now only enter the bun path when bun.lock is present; otherwise we go straight to npm ci. 2. OXC validator lockfile was outside the npm supply-chain scan surface. lockfile_supply_chain_audit.py default, npm audit, OSV, scan_npm_packages.py invocation, and the diff-for-new-install-scripts step all now cover both lockfiles. security-audit.yml pull_request paths filter triggers on changes to either. wheel-smoke checks the built wheel ships the OXC lockfile too. Verified: python3 scripts/lockfile_supply_chain_audit.py > OK: 0 findings across 2 npm + 1 cargo lockfile(s) python3 scripts/scan_npm_packages.py --lockfile oxc-validator/... > OK
Reviewer found a real bug in 0ddfc10: the new two-scan step ran under `set -o pipefail` (default `set -e` from the step shell), so a HIGH or CRITICAL on the frontend lockfile would abort the step before the OXC scan ran. Both reports are most useful exactly when one has already failed. Capture each rc via PIPESTATUS, run both scans unconditionally, write both into the step summary, and only then propagate the worst rc.
Two blockers from the parallel Opus review batch: 1. The Tauri CLI install in release-desktop.yml was the last unfrozen install path: `npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit` pins the top-level version but leaves the transitive closure floating, defeats the pre-install lockfile audit (no lockfile to scan), and skips integrity verification. Committed a minimal studio/package.json (devDep @tauri-apps/cli@2.10.1) plus the resolved studio/package-lock.json (12 packages: CLI + 11 platform-native binaries, all with integrity hashes, lockfileVersion 3). Switched the step to `npm ci --prefix studio` and added a pre-install lockfile_supply_chain_audit.py step ahead of it so any tarball postinstall is gated by the structural scan. Allowlisted studio/package-lock.json .gitignore and added it to the audit script's default scan set. 2. The bun branch was dead code in build.sh, studio/setup.sh, and studio/setup.ps1: nowhere in the repo is a bun.lock committed, and `bun install --frozen-lockfile` cannot migrate from package-lock.json. With no lockfile, every entry to the bun path either silently regenerates a bun.lock (under permissive install modes -- a fresh attack surface) or fails outright (under frozen-lockfile). Removed `npm install -g bun` bootstrap, the `_try_bun_install` helper + cache-retry, every `if bun.lock && command -v bun` guard, and the now-unreachable "fall back to npm" messaging. All three scripts now have a single `npm ci` path. bun.lock skip entries in lint-ci.yml + wheel-smoke.yml are kept as forward-compat sanity checks -- they assert bun.lock is NOT shipped / scanned, which is stronger after this commit, not weaker. Smoke-tested locally: `npm ci --prefix studio` resolves 3 packages (CLI + 2 linux native binaries), `npx --prefix studio tauri --version` prints `tauri-cli 2.10.1` exactly. `python3 scripts/lockfile_supply_chain_audit.py` scans 3 npm + 1 cargo lockfiles, 0 findings. `bash -n build.sh`, `bash -n studio/setup.sh`, and a pwsh scriptblock parse of studio/setup.ps1 all succeed.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request standardizes dependency management by replacing bun and npm install with npm ci across build and setup scripts, ensuring builds follow committed lockfiles. It introduces new lockfiles for the studio and OXC validator components and updates the supply chain audit script. Review feedback suggests adding --no-fund, --no-audit, and --loglevel=error flags to npm ci commands in build.sh and setup.ps1 for consistency and reduced log noise.
| # would always miss and silently regenerate (or fail under | ||
| # --frozen-lockfile). Keep this single path until/unless a real | ||
| # bun.lock lands. | ||
| if ! npm ci; then |
| Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow | ||
| exit 1 | ||
| } | ||
| $npmExit = Invoke-SetupCommand { npm ci } |
| Push-Location $OxcValidatorDir | ||
| $oxcInstallExit = Invoke-SetupCommand { npm install } | ||
| # npm ci: lockfile-strict (see frontend install above). | ||
| $oxcInstallExit = Invoke-SetupCommand { npm ci } |
There was a problem hiding this comment.
Code Review
This pull request transitions the project's dependency management from a mix of bun and npm install to a strict npm ci workflow across build and setup scripts. Key changes include un-ignoring and adding several package-lock.json files, removing bun installation logic, and updating the lockfile audit script. Review feedback highlights a missing lockfile for the frontend and suggests adding consistent flags (--no-fund, --no-audit, --loglevel=error) to npm ci commands to minimize log output.
| *.log | ||
| # Ignore stray lockfiles; real npm projects opt back in below (npm ci needs them). | ||
| package-lock.json | ||
| !studio/frontend/package-lock.json |
There was a problem hiding this comment.
The studio/frontend/package-lock.json file is un-ignored here, but it does not appear to be included in the pull request's added files. Since the build and setup scripts have been updated to use npm ci, they will fail if this lockfile is not committed to the repository. Please ensure that the frontend lockfile is added to the commit.
| # would always miss and silently regenerate (or fail under | ||
| # --frozen-lockfile). Keep this single path until/unless a real | ||
| # bun.lock lands. | ||
| if ! npm ci; then |
There was a problem hiding this comment.
| Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow | ||
| exit 1 | ||
| } | ||
| $npmExit = Invoke-SetupCommand { npm ci } |
There was a problem hiding this comment.
| Push-Location $OxcValidatorDir | ||
| $oxcInstallExit = Invoke-SetupCommand { npm install } | ||
| # npm ci: lockfile-strict (see frontend install above). | ||
| $oxcInstallExit = Invoke-SetupCommand { npm ci } |
Brings the parallel CI paths into line with the lockfile-pinned
release path and tightens the supply-chain audit surface:
studio-tauri-smoke.yml: run lockfile_supply_chain_audit.py before
the Tauri CLI install, and install via `npm ci --prefix studio`
against the committed studio/package-lock.json (was a mutable
`npm install --save-dev` post-audit). This relocates the existing
pre-install lockfile supply-chain audit step; the step's name and
command are preserved verbatim so its purpose is unchanged, only
its position relative to the install. The earlier security
rationale about lifecycle scripts and the postinstall-dropper
class is preserved on the Frontend build step where it actually
applies (vite/esbuild lifecycle scripts run on the frontend
install); the Tauri CLI install step gets a new rationale tied to
`npm ci` semantics.
security-audit.yml:
* add studio/package.json and studio/package-lock.json to the PR
path filter so a Tauri CLI lockfile change cannot bypass the
workflow,
* extend OSV-Scanner, scan_npm_packages.py (with LOG3 and exit-
code propagation), and the install-script diff to cover
studio/package-lock.json,
* add an npm audit step for the Tauri CLI holder project,
* extend the npm-provenance-and-install-scripts job with
--ignore-scripts installs + npm audit signatures for the
oxc-validator and Tauri CLI holder projects; the existing
frontend audit-signatures step is renamed to "(Studio
frontend, informational)" purely for disambiguation against
the two new sibling steps, with its log path rerouted through
$GITHUB_WORKSPACE so a single artifact upload can collect all
three logs,
* update the lockfile-audit step summary to list the Tauri CLI
holder lockfile,
* fix the stale "Initially non-blocking" comment on the now-
blocking npm scan-packages step.
build.sh and studio/setup.ps1 (oxc): pass --no-fund --no-audit to
npm ci for parity with the other call sites.
studio/setup.sh and studio/setup.ps1: restore the bun.lock
exclusion in the frontend staleness check so a leftover local
bun.lock from the migration does not trigger a spurious rebuild.
scripts/lockfile_supply_chain_audit.py: emit a HIGH-severity
missing-lockfile Finding when a requested lockfile does not
exist, so a deleted default cannot silently pass the audit. Uses
the script's own Finding accumulator pattern (sibling
scripts/scan_npm_packages.py implements the same intent via an
rc=2 hard-fail, its single-lockfile-per-invocation idiom; this
script aggregates multiple lockfiles so Finding is the natural
channel).
scripts/check_frontend_dep_removal.py: add studio/package.json
and studio/package-lock.json to EXPECTED_NOISE_FILES; the new
Tauri CLI holder manifests must not count as frontend dep usage.
When a caller passes an explicit --npm-lockfile or --cargo-lockfile, they are scoping the scan to the paths they listed; the script was still silently grafting the other ecosystem's defaults on top, which meant `--npm-lockfile X` would also audit DEFAULT_CARGO_LOCKFILES. With the missing-lockfile Finding now emitted, that surfaced as a false positive whenever a caller explicitly scoped only one ecosystem. Default fallback is now reserved for the no-args CI invocation, where every default path is expected to exist.
Collapse 4-6 line "why this matters" blocks to 1-2 lines stating the single load-bearing fact in lockfile_supply_chain_audit.py (missing- lockfile finding rationale, CLI default-scoping rationale) and in studio-tauri-smoke.yml (pre-install audit ordering, npm ci semantics, frontend lifecycle-script context). No behaviour change.
f8ffd61 to
7238504
Compare
Staging mirror of unslothai#5479
Original PR: unslothai#5479
Author: danielhanchen
This is a staging copy for review and editing. Once finalized, changes will be pushed back to the original PR.
Original description
Summary
Closes a supply-chain risk that ran through every install path Unsloth Studio uses. Every script that builds or installs the frontend called naked
bun installandnpm install. With caret ranges instudio/frontend/package.json(the default for most deps), those commands resolve a fresh minor or patch from the registry if one is available, even thoughpackage-lock.jsonpins specific versions. An attacker who hijacks any transitive dep and publishes a malicious patch release would have it pulled into the next release build, the next desktop signed artifact, or the next end-user install with no visible signal.The fix is the standard hardening: switch every install path to lockfile-strict mode.
build.sh:39bun installbun install --frozen-lockfilebuild.sh:47npm install(fallback)npm ci(fallback)studio/setup.sh:346bun installbun install --frozen-lockfilestudio/setup.sh:384npm install --no-fund --no-audit --loglevel=error(fallback)npm ci --no-fund --no-audit --loglevel=error(fallback)studio/setup.sh:420npm install --no-fund --no-audit --loglevel=error(OXC validator)npm ci --no-fund --no-audit --loglevel=error(OXC validator)studio/setup.ps1:1338bun install(Windows installer)bun install --frozen-lockfilestudio/setup.ps1:1352bun install(Windows cache-clear retry)bun install --frozen-lockfilestudio/setup.ps1:1371npm install(Windows fallback)npm cistudio/setup.ps1:1414npm install(Windows OXC validator)npm ci.github/workflows/release-desktop.yml:452npm install --no-fund --no-auditnpm ci --no-fund --no-auditBoth
--frozen-lockfile(bun) andnpm ciinstall only what the lockfile pins, verify cryptographic hashes, and abort with a clear error ifpackage.jsonand the lockfile have drifted. This matches what the CI workflows already do (studio-frontend-ci.yml,studio-tauri-smoke.yml,wheel-smoke.yml,security-audit.ymlall usenpm ci).Why this matters
A worked example using a recent incident: the May 2026 TanStack Mini Shai-Hulud attack (GHSA-g7cv-rxg3-hmpx) published malware as new patch versions of 42
@tanstack/*packages. If a transitive consumer had"@tanstack/router-core": "^1.169.2"in their dep tree and ran nakednpm installduring a release build before the bad versions were yanked, npm would resolve to the malicious 1.169.5 or 1.169.8.npm ciagainst a clean lockfile pinned to 1.169.2 would skip the compromised versions entirely.The same pattern applies to any transitive dep with a caret range, which is the vast majority of npm packages.
The
release-desktop.ymlsite is the most important one: that workflow builds the signed.app,.dmg,.AppImage, and.msiartifacts that ship to end users. Before this PR, a single hijacked transitive could land in a release artifact between the