Author: | Rob Pulsipher |
---|---|
Date: | September 20, 2025 |
I was hoping to actually get something done today.
Then I found out about Shai-Hulud and spent the rest of the day trying to determine how likely it is that I've been pwned,
as I've been quite active installing npm packages lately.
The following are my notes (in progress) on a few general NPM security best better practices to adopt going forward.
By default, npm runs any scripts defined by any packages that you install. That is kind of essential for things "just working", but isn't a safe practice. It is probably better to turn this off in general, and authorize it as needed.
Using npm install --ignore-scripts
will avoid running any scripts,
and this can also be configured in .npmrc
.
Add it in the home directory and every project directory.
This will guard locally and also protect anyone who clones the repository.
ignore-scripts=true
If it is necessary to run with scripts enabled, this can be done explicitly:
npm install --ignore-scripts=false
This option will be needed in ci/cd scripts when dependencies require scripts to work.
If a specific package fails because it's script did not run, it can be run individually with:
npm rebuild examplePackage --ignore-scripts=false
Edit .npmrc, and add:
save-exact=true
or
npm config set save-exact true
This will cause newly installed packages to receive a specific "pinned" version.
However, while this is an improvement, it doesn't pin the versions of transitive dependencies.
Deleting package-lock.json
can still allow transitive dependencies to get bumped.
This script can be used to convert imprecise dependencies in package.json into pinned versions.
(Run with node pinVersions.js
)
//pinVersions.js
// This is a script to update package.json with the precise, locked versions from package-lock.json
// Place in same directory as package.json
const fs = require("fs");
const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf8"));
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
for (const depType of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
if (!pkg[depType]) continue;
for (const dep of Object.keys(pkg[depType])) {
if (lock.packages[`node_modules/${dep}`]) {
pkg[depType][dep] = lock.packages[`node_modules/${dep}`].version;
}
}
}
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2));
console.log("package.json updated with pinned versions!");
npm ci
When Possiblenpm install
will run with or without package-lock.json
. If it is present, it tries to follow it,
but it may update it.
npm ci
requires package-lock.json
and requires it to be in sync with package.json
. It will only
install the specific versions specified in package-lock.json
.
Always use npm ci
with ci/cd processes. Using npm install
is incorrect; the ci build process never needs
to alter package.json
or package-lock.json
.
Using npm install newPackage --package-lock-only
will resolve the package,
and write it into package.json/package-lock.json, but not actually install it into node_modules
or run scripts. This allows an opportunity to use see what it does before it actually does it.
Possibly run a dependency scanner before actual installation.
This can be followed by npm ci --ignore-scripts
to install the packages without the scripts.
Then this script can be used to enumerate any scripts that the dependencies want to run (outputs nothing if there are no scripts):
find node_modules -type f -name package.json | while read -r pkg; do
name=$(grep -m1 '"name"' "$pkg" | sed -E 's/.*"name": *"([^"]+)".*/\1/')
version=$(grep -m1 '"version"' "$pkg" | sed -E 's/.*"version": *"([^"]+)".*/\1/')
preinstall=$(grep -A1 '"preinstall"' "$pkg" | head -n1 | sed -E 's/.*"preinstall": *"([^"]+)".*/\1/' || true)
install=$(grep -A1 '"install"' "$pkg" | head -n1 | sed -E 's/.*"install": *"([^"]+)".*/\1/' || true)
postinstall=$(grep -A1 '"postinstall"' "$pkg" | head -n1 | sed -E 's/.*"postinstall": *"([^"]+)".*/\1/' || true)
if [ -n "$preinstall" ] || [ -n "$install" ] || [ -n "$postinstall" ]; then
echo "$name@$version"
[ -n "$preinstall" ] && echo " preinstall: $preinstall"
[ -n "$install" ] && echo " install: $install"
[ -n "$postinstall" ]&& echo " postinstall: $postinstall"
fi
done
The script above is precise, but SSSSSLLLLLOOOOOOOWWWWWW. This yields some noise but is fast:
grep -R -nE '"(install|preinstall|postinstall)"' node_modules
Finally, if the scripts are acceptable,
delete node_modules,
and run npm ci --ignore-scripts=false
(overriding global configuration)
to install the scripts.
(Or use npm rebuild
as detailed above to reinstall individual packages with scripts.)