(Notes) NPM Hygiene in the Wake of Shai-Hulud

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.

Disable Automatic Script Execution

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

Package Pinning

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!");

Use npm ci When Possible

npm 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.

Resolve a Package Without Installing

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.)

Reusabit Software LLC Digital Signal