
Before GitHub Disables npm Script Installs: The package.json Checks I’m Running Now
Why GitHub’s npm script-install change matters now
The report says GitHub is moving toward automating the disabling of npm script installs as a supply-chain defense. That lines up with a pattern I keep seeing in audits: package installation gets treated like a file download, when it can behave like code execution.
The awkward part is not that lifecycle scripts exist. It is that they are ordinary enough to fade into the background. A dependency can arrive through a lockfile, look harmless in review, and still run preinstall, install, postinstall, or prepare during install.
That matters because install time usually happens in places where trust is high and logging is thin:
- developer laptops
- CI runners with broad tokens
- ephemeral preview environments
- internal build agents that can reach private package registries
- release jobs that can publish artifacts
If GitHub automates script-install disablement, the blast radius gets smaller in a useful way. You do not have to rely on every developer remembering --ignore-scripts or every CI job setting npm_config_ignore_scripts=true. The platform can push a safer default into more places.
That said, automation is not the whole fix. It will expose breakage in packages that really do depend on install-time code. So before that rollout lands in a repo or pipeline, I would audit manifests and lockfiles as if every script were a tiny build system hidden inside node_modules.
What lifecycle scripts can do during install
Lifecycle scripts are shell commands attached to package events. In practice, they can:
- generate files
- compile native addons
- download binaries
- rewrite config
- inspect the environment
- reach out to the network
- read tokens that happen to be present in CI
- chain into other local executables
The key detail is that the script runs with the installer’s privileges. If the install job can see secrets, private registries, or internal network routes, the script usually can too.
A few hooks matter most:
preinstallinstallpostinstallprepareprepublishOnly
If a package uses any of them, I assume the author expected code to run during install or publish. Sometimes that is legitimate. Sometimes it is just convenience that grew into a hidden dependency.
Why an automated disablement changes the supply-chain blast radius
The supply-chain risk here is not only malicious packages. It is also the accidental surface area created by normal ones:
- a maintainer adds a postinstall build step
- a transitive dependency starts downloading a binary
- a git dependency relies on
prepare - a workspace package runs setup code during install
If the platform disables script installs automatically, the risk shifts in two directions:
- Malicious code loses an easy execution path.
- Benign but implicit build behavior becomes visible because it breaks.
That second point is the useful one. Breakage forces a conversation about where build steps belong. I would rather discover that a package depends on a hidden install step in CI than find out after a dependency compromise.
What I am assuming when I audit a repo before the rollout
When I review a repo for this change, I assume:
- the lockfile is authoritative, but not sufficient
- transitive dependencies matter as much as direct ones
- a clean top-level
package.jsoncan still hide risky workspace packages - install-time network access is a bug unless someone can justify it
- any script that touches
process.envmight expose secrets - any script that downloads code or binaries deserves extra scrutiny
That gives me a baseline. The rest of the audit is about finding where the install path can still execute.
The package.json fields I check first
scripts: preinstall, install, postinstall, prepare, and prepublishOnly
I start with scripts because that is where most install-time behavior hides.
| Field | Why I care | Typical risk |
|---|---|---|
preinstall | Runs before dependency installation finishes | Environment inspection, setup, tampering |
install | Runs during install | Native builds, binary downloaders, arbitrary shell |
postinstall | Runs after install | Most common place for hidden setup code |
prepare | Often used for git dependencies and pre-pack build steps | Build-on-install surprises |
prepublishOnly | Runs before publish, but can still indicate hidden release logic | Supply-chain hygiene and release risk |
My first pass is simple: if the repo has any of these, I want to know why.
A harmless script looks like this:
{
"scripts": {
"test": "node test.js",
"build": "tsc -p tsconfig.json"
}
}
A more interesting one looks like this:
{
"scripts": {
"postinstall": "node scripts/setup.js",
"prepare": "node scripts/build.js"
}
}
That second version is not automatically bad. But now I need to inspect scripts/setup.js and scripts/build.js, plus the dependency graph that makes them run.
dependencies versus devDependencies versus optionalDependencies
The dependency class changes the risk profile, but it does not remove it.
dependenciesare the obvious risk because they ship with the application.devDependenciesstill matter because they execute in development and often in CI.optionalDependenciesare often used for platform-specific extras and can still run install logic.peerDependenciesusually do not bring code themselves, but they shape what gets installed around them.
The trap is thinking, “It is only a devDependency, so production is safe.” That can be true for runtime code and false for the install environment. If CI installs dev dependencies with full permissions, the script still runs there.
I also look for package managers that omit dev deps in one stage but not another. That mismatch is where surprises show up:
- local install includes dev scripts
- CI install omits them
- production image copies
node_modulesfrom an earlier step - a postinstall script writes files that later get baked into an artifact
overrides, bundledDependencies, bin, and workspace boundaries
These fields do not always execute code, but they change trust boundaries.
overridescan pin or redirect transitive packages, which is useful for control but easy to misuse if it hides a risky replacement.bundledDependenciesmeans the package ships with vendored dependencies. That can make review harder because you are trusting a prebuilt bundle, not just a manifest.binexposes CLI entrypoints. It is not an install hook by itself, but packages withbinoften have support scripts to prepare the executable.- workspace boundaries matter because the root manifest can look clean while a nested workspace package still has lifecycle scripts.
My rule here is simple: a clean root is not the same thing as a clean repository.
How to map the install path without trusting the package
Read the lockfile and identify which packages actually execute scripts
The lockfile tells you what will be installed, but not every package in the tree deserves the same attention. I usually split the list into three buckets:
- direct dependencies
- transitive packages with scripts
- packages that only ship static files
In npm, I inspect the lockfile and then trace anything with lifecycle hooks. If I find a package that downloads a binary or builds native code, it goes to the top of the list.
Useful commands:
npm ls --all
npm explain <package-name>
npm view <package-name> scripts --json
npm view is especially useful for published packages because it lets you check registry metadata without unpacking anything locally. If a package advertises postinstall or prepare, I assume it executes code unless I can prove otherwise.
Use npm metadata and local inspection to find hidden lifecycle hooks
For local packages and git dependencies, registry metadata is not enough. I inspect the source tree directly:
cat package.json
find . -maxdepth 2 -name package.json -print
grep -R "\"postinstall\"\\|\"prepare\"\\|\"install\"\\|\"preinstall\"" -n .
If the package is not huge, I also search for the usual patterns:
child_processcurl,wget, orfetchprocess.envnode-gypprebuild,prebuild-installsharp,esbuild,canvas, or other native-heavy dependencies
The goal is not to prove malicious intent. The goal is to understand what happens when the installer runs.
Spot the difference between direct dependencies and transitive risk
A lot of teams only review top-level dependencies. That misses the real problem.
Consider this pattern:
- your app depends on
A Adepends onBBhas a postinstall script- the script runs in your CI even though you never imported
Bdirectly
That is why lockfile review matters. A transitive package can become the actual execution point.
I find it useful to keep a simple table during review:
| Package | Direct or transitive | Has lifecycle script? | Needs network? | Needs native build? |
|---|---|---|---|---|
| app root | direct | no | no | no |
tooling-lib | direct | yes | maybe | maybe |
binary-fetcher | transitive | yes | yes | no |
native-addon | transitive | yes | no | yes |
That table makes the install path visible fast.
Safe ways to reproduce the behavior in a throwaway repo
Create a harmless demo package that logs lifecycle execution
When I want to prove the behavior to a team, I build a tiny demo package that only logs to the console or writes a local file. No network, no secrets, no real payload.
Create a package like this:
{
"name": "demo-package",
"version": "1.0.0",
"private": true,
"scripts": {
"preinstall": "node scripts/log.js preinstall",
"install": "node scripts/log.js install",
"postinstall": "node scripts/log.js postinstall",
"prepare": "node scripts/log.js prepare"
}
}And a tiny logger:
const fs = require("fs");
const path = require("path");
const hook = process.argv[2] || "unknown";
const out = path.join(__dirname, "..", "install-trace.txt");
const line = [
new Date().toISOString(),
hook,
`npm_lifecycle_event=${process.env.npm_lifecycle_event || ""}`
].join(" | ") + "\n";
fs.appendFileSync(out, line);
console.log(line.trim());Then install it locally from a second repo with a file reference:
{
"dependencies": {
"demo-package": "file:../demo-package"
}
}
That setup is enough to show when the hooks fire.
Compare npm install, npm ci, and package manager defaults
The next step is to compare installer behavior. I care about three cases:
npm installnpm ci- installer runs with scripts disabled
On npm, both install and ci can execute lifecycle scripts unless you tell them not to. The difference is reproducibility, not script safety.
I usually run:
npm install
npm ci
npm install --ignore-scripts
npm ci --ignore-scripts
Then I compare the trace file.
If you use other package managers, check their defaults too:
pnpmhas its own script controlsyarnhas its own script controls- CI wrappers may override package manager behavior without making it obvious
The important lesson is not the exact flag. The lesson is that you should know which install mode your repo actually uses.
Verify what changes when scripts are disabled or restricted
This is where I like to make the difference explicit.
| Mode | Expected result |
|---|---|
| normal install | lifecycle hooks run |
| scripts disabled | hooks do not run |
| restricted install | only trusted hooks or approved packages run |
| offline install | network-dependent hooks fail fast |
If a package breaks when scripts are disabled, I want to know why. Sometimes the failure is justified: a native addon needs compilation. Sometimes it is a bad design choice: a package downloads a prebuilt binary every time instead of shipping an artifact.
I also check the environment during the demo. If the script can read npm_config_* values or other env vars, I treat that as a reminder that install-time code sees more than just package files.
Dependency patterns that deserve extra scrutiny
Git dependencies and packages that rely on prepare
Git dependencies are the first thing I inspect when a repo depends on install-time scripts. The reason is simple: git sources often need prepare to build or bundle files before they become usable.
That creates a hidden flow:
- clone from git
- run
prepare - generate package artifacts
- install the package
The risk is not only malicious code. The issue is that the artifact you end up using may be produced by code that never went through the same review path as a published tarball.
If a package depends on prepare, I ask:
- is the build reproducible?
- are the generated files committed?
- can the package be published without local build steps?
- is the git source trusted more than the registry package?
If the answer is no, I usually prefer a published, locked artifact over a live git dependency.
Native addons, postinstall builds, and platform-specific installs
Native addons are legitimate, but they are also the classic install-time script use case. They often need to compile against local headers, which is why packages invoke node-gyp or similar tooling.
That creates some practical checks:
- does the package really need a native addon?
- can it ship prebuilt artifacts instead?
- does the install script choose the correct platform safely?
- what happens when compilation fails?
- does the build step touch the network?
Native packages are noisy, but they are not automatically risky. The concern is whether the build step does more than compile.
Packages that download binaries at install time
This is the pattern I trust least.
A package that runs a postinstall script to download a binary is asking the installer to fetch executable code from somewhere else. Sometimes the target is a vendor CDN. Sometimes it is a release asset. Sometimes it is a mirror. Either way, the trust boundary shifts.
My checklist for these packages:
- is the binary checksum verified?
- is the download URL pinned?
- does the package fail closed when verification fails?
- can the binary be shipped in the artifact instead?
- is there a clear release process for the binary?
If the answer to checksum verification is vague, I treat the package as a red flag.
What breaks when you disable scripts, and how to keep shipping
Move build steps out of install and into CI
The cleanest fix is usually to stop doing build work during install.
Instead of this:
- install package
- compile assets in
postinstall - hope the environment is correct
Do this:
- build in CI
- publish built artifacts
- install the artifact without extra shell work
That separation is healthier because CI can be audited, logged, and isolated. Install time should fetch dependencies, not decide how the build works.
Replace postinstall behavior with explicit release-time artifacts
If a package needs generated files, I prefer one of these patterns:
- commit the generated artifact if it is small and stable
- publish the generated artifact as part of the release tarball
- generate it in a dedicated release job, not on every install
For app repos, that usually means moving setup work into:
prebuild- a CI job
- a release workflow
- a Docker build stage
The key is that the behavior becomes explicit. Anyone reading the pipeline can see when code is produced.
Pin versions and use lockfiles to reduce surprise changes
Script disablement does not solve version drift. You still need to pin dependencies tightly enough to avoid surprise behavior.
I want:
- a committed lockfile
- predictable install commands in CI
- version ranges reviewed intentionally
- updates done in controlled batches
Lockfiles do not remove risk, but they reduce the surface where new lifecycle scripts can appear without notice. A package that was safe last week can gain a new install step in a later release. Pinning makes that change visible instead of automatic.
My defensive checklist for package.json reviews
Strip unnecessary lifecycle hooks
My first question is whether the lifecycle hook is still needed.
If a package has a postinstall because someone once needed to copy a file or run a build, I try to remove it. A little convenience is rarely worth a hidden execution path.
I look for these cleanup opportunities:
- replace install-time setup with documented CLI steps
- move one-time initialization to a manual command
- commit generated files when practical
- avoid shell scripts for trivial file operations
Check for install-time network access and secret access
If a script reaches the network during install, I want to know why.
If a script can read env vars during install, I want to know which ones matter.
At minimum, I test with a restricted CI-like environment:
- no secret tokens unless required
- no write access outside the workspace
- no network unless the build absolutely needs it
- read-only dependency cache where possible
This catches more than malicious behavior. It also exposes sloppy assumptions in build scripts.
Review workspace packages separately from the root manifest
Monorepos make people lazy. The root manifest looks clean, so they stop there. That is the wrong place to stop.
I review each workspace package on its own:
- package-level scripts
- nested lockfiles, if any
- package-specific install steps
- cross-package build assumptions
A root package.json with no lifecycle hooks tells me almost nothing if packages/tooling/package.json has a postinstall that runs every time.
Confirm least-privilege CI tokens and isolated install environments
The damage from a bad install script depends on what the environment can see.
So I make sure CI jobs that install dependencies use:
- scoped tokens
- minimal registry permissions
- isolated workspaces
- no long-lived cloud creds
- separate build and release stages
If a dependency install can publish artifacts, touch production systems, or read secret stores, the install environment is too powerful.
Edge cases where script disabling can be noisy
Legitimate build tooling that still needs scripts
Some packages really do need lifecycle scripts. I do not treat every script as a mistake.
Examples include:
- native module compilation
- asset generation for publishing
- monorepo build orchestration
- binary compatibility checks
The trick is to keep the necessity narrow and documented. A justified install script should have a clear reason and a bounded effect.
If I cannot explain why the hook exists, I assume it is there out of habit.
Monorepos with mixed package managers or nested manifests
A mixed-tooling monorepo is where clean policy goes to die.
You might have:
- npm at the root
- pnpm in a workspace
- yarn in a legacy package
- nested manifests with different install expectations
That makes script disablement harder because one package manager may ignore a behavior that another still allows. If you are rolling out stricter install settings, audit the whole tree, not just the root.
False confidence from a clean top-level package.json
This is the easiest mistake to make.
The root manifest has no lifecycle scripts, the lockfile looks tidy, and the CI job passes. Then a nested workspace package or transitive dependency does something unexpected during install.
A clean top-level file is useful, but it is not proof of safety. It is only the starting point.
Conclusion: the safest install is the one you already understand
GitHub’s move to automate disabling npm script installs is a useful stress test. It forces teams to decide whether their dependencies are being installed or executed.
That is the audit I want before a rollout like this:
- know which packages have lifecycle hooks
- know which of them actually run in your environments
- know where network access and secrets are available
- know which packages break when scripts are disabled
- move hidden build work into CI or release steps
If you can answer those questions, the change is mostly cleanup. If you cannot, the package tree still contains assumptions you have not checked yet.
The practical next step for maintainers and app teams
Start with one repo and one lockfile.
- List all packages with lifecycle scripts.
- Separate direct dependencies from transitive ones.
- Disable scripts in a throwaway install and note what breaks.
- Move the broken-but-legitimate work into CI.
- Remove any script that exists only because it was convenient.
That gives you a repo that does not rely on install-time magic. And in my experience, that is the safest place to be before any platform starts tightening the defaults.


