Skip to content

Instantly share code, notes, and snippets.

@whomwah
Last active April 9, 2026 09:45
Show Gist options
  • Select an option

  • Save whomwah/591ab890034b1623847e4c9d6056c7ed to your computer and use it in GitHub Desktop.

Select an option

Save whomwah/591ab890034b1623847e4c9d6056c7ed to your computer and use it in GitHub Desktop.
Hardens NPM, Bun, PNPM, and Yarn against supply chain attacks by writing sensible security defaults to their global config files.
#!/usr/bin/env bash
set -euo pipefail
# harden-js-package-managers.sh
#
# Hardens npm, Bun, pnpm, and Yarn against supply chain attacks by
# writing sensible security defaults to their global config files.
#
# Sets a 7-day minimum release age gate on all four package managers.
# New package versions won't be installed until they've been live on
# the registry for at least 7 days — long enough for the community
# to catch and report compromised releases. This alone would have
# blocked 11 of 21 major supply chain attacks between 2018–2026,
# including axios, Solana web3.js, and ua-parser-js.
#
# Safe to re-run (idempotent). Use --undo to reverse all changes.
# See --help for full usage, caveats, and minimum version requirements.
RELEASE_AGE_DAYS=7
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
DIM='\033[2m'
RESET='\033[0m'
UNDO=false
SKIP_CONFIRM=false
# ─── Resolve platform-specific pnpm global config path ───────────────
# macOS: ~/Library/Preferences/pnpm/rc
# Linux: ~/.config/pnpm/rc
# Windows (Git Bash/WSL): ~/AppData/Local/pnpm/config/rc
get_pnpm_rc() {
case "$(uname -s)" in
Darwin) echo "$HOME/Library/Preferences/pnpm/rc" ;;
MINGW*|MSYS*|CYGWIN*)
echo "$HOME/AppData/Local/pnpm/config/rc" ;;
*) echo "${XDG_CONFIG_HOME:-$HOME/.config}/pnpm/rc" ;;
esac
}
# ─── Resolve the correct bunfig path ─────────────────────────────────
# Global (home dir): ~/.bunfig.toml (dotfile)
# Project-level: bunfig.toml (no dot)
# If an existing file is found, prefer it over the default.
resolve_bunfig() {
local dir="$1"
local dotfile="$dir/.bunfig.toml"
local plainfile="$dir/bunfig.toml"
# If either already exists, use that one
if [[ -f "$dotfile" ]]; then
echo "$dotfile"
elif [[ -f "$plainfile" ]]; then
echo "$plainfile"
# Otherwise, use the conventional name for the context
elif [[ "$dir" == "$HOME" ]]; then
echo "$dotfile" # global config uses dotfile
else
echo "$plainfile" # project config uses plain name
fi
}
# ─── Resolve the correct yarnrc path ─────────────────────────────────
# Yarn 4+ uses .yarnrc.yml (YAML). Yarn Classic uses .yarnrc (INI).
# npmMinimalAgeGate only exists in Yarn 4+, so we target .yarnrc.yml.
# If only .yarnrc exists, the user is likely on Yarn Classic — warn them.
resolve_yarnrc() {
local dir="$1"
local ymlfile="$dir/.yarnrc.yml"
local classicfile="$dir/.yarnrc"
if [[ -f "$ymlfile" ]]; then
echo "$ymlfile"
elif [[ -f "$classicfile" && ! -f "$ymlfile" ]]; then
# Yarn Classic detected — still write .yarnrc.yml but warn
printf "${YELLOW}[!]${RESET} %s\n" \
"Found .yarnrc (Yarn Classic) but no .yarnrc.yml."
printf " %s\n" \
"npmMinimalAgeGate requires Yarn >= 4.10. If you're on Yarn Classic,"
printf " %s\n\n" \
"this setting will have no effect."
echo "$ymlfile"
else
echo "$ymlfile"
fi
}
usage() {
local cmd
cmd="$(basename "$0")"
local pnpm_rc
pnpm_rc="$(get_pnpm_rc)"
cat <<EOF
Usage: $cmd [OPTIONS] [TARGET_DIR]
Sets a ${RELEASE_AGE_DAYS}-day minimum release age gate across npm, Bun, pnpm, and
Yarn so newly published package versions are not installed until
they've survived a week of community scrutiny on the registry.
Arguments:
TARGET_DIR Directory to write config files into (default: \$HOME)
Affects .npmrc, bunfig.toml, and .yarnrc.yml.
pnpm global config is always written to its
platform-specific path (currently: $pnpm_rc).
Options:
-h, --help Show this help message and exit
-u, --undo Remove all settings previously added by this script
-y, --yes Skip confirmation prompt
What it configures:
.npmrc min-release-age=${RELEASE_AGE_DAYS} (days)
.bunfig.toml [install] minimumReleaseAge = $((RELEASE_AGE_DAYS * 86400)) (seconds)
or bunfig.toml (dotfile for global, plain for project-level)
pnpm global rc minimum-release-age=$((RELEASE_AGE_DAYS * 1440)) (minutes)
.yarnrc.yml npmMinimalAgeGate: "${RELEASE_AGE_DAYS}d" (Yarn 4+ only)
Minimum versions required:
npm >= 11.10.0 pnpm >= 10.16
Yarn >= 4.10.0 Bun >= 1.3
Caveats:
* npm does not yet support exclusion rules for min-release-age.
If you publish internal packages, pnpm's exclusion config is
the better option for now.
* pnpm >= 11 defaults to minimumReleaseAge of 1440 (1 day). This
script sets it to $((RELEASE_AGE_DAYS * 1440)) minutes (${RELEASE_AGE_DAYS} days).
* Yarn Classic (.yarnrc) does not support npmMinimalAgeGate.
If you're still on Yarn 1, this setting will have no effect.
Examples:
$cmd # writes to \$HOME (with confirmation)
$cmd -y # skip confirmation
$cmd ./my-project # writes to ./my-project
$cmd --undo # remove all added settings from \$HOME
$cmd --undo ./proj # remove all added settings from ./proj
EOF
exit 0
}
# ─── Arg parsing ─────────────────────────────────────────────────────
POSITIONAL=()
for arg in "$@"; do
case "$arg" in
-h|--help) usage ;;
-u|--undo) UNDO=true ;;
-y|--yes) SKIP_CONFIRM=true ;;
*) POSITIONAL+=("$arg") ;;
esac
done
TARGET_DIR="${POSITIONAL[0]:-$HOME}"
PNPM_RC="$(get_pnpm_rc)"
log() { printf "${GREEN}[✓]${RESET} %s\n" "$1"; }
skip() { printf "${YELLOW}[–]${RESET} %s (already set)\n" "$1"; }
removed(){ printf "${RED}[✗]${RESET} %s\n" "$1"; }
noop() { printf "${YELLOW}[–]${RESET} %s (not found, nothing to do)\n" "$1"; }
# ─── Helpers: apply mode ─────────────────────────────────────────────
ensure_line() {
local file="$1" pattern="$2" line="$3" label="$4"
mkdir -p "$(dirname "$file")"
touch "$file"
if grep -qF "$pattern" "$file" 2>/dev/null; then
skip "$label in $file"
else
printf '%s\n' "$line" >> "$file"
log "$label$file"
fi
}
# ─── Helpers: undo mode ─────────────────────────────────────────────
remove_line() {
local file="$1" pattern="$2" label="$3"
if [[ ! -f "$file" ]]; then
noop "$label in $file"
return
fi
if grep -qF "$pattern" "$file" 2>/dev/null; then
grep -vF "$pattern" "$file" > "${file}.tmp" || true
mv "${file}.tmp" "$file"
removed "Removed $label from $file"
else
noop "$label in $file"
fi
if [[ -f "$file" ]] && ! grep -q '[^[:space:]]' "$file" 2>/dev/null; then
rm "$file"
printf " ${DIM}(removed empty %s)${RESET}\n" "$file"
fi
}
# Try to remove a setting from either bunfig variant.
remove_bunfig_line() {
local dir="$1" pattern="$2" label="$3"
local found=false
for candidate in "$dir/.bunfig.toml" "$dir/bunfig.toml"; do
if [[ -f "$candidate" ]] && grep -qF "$pattern" "$candidate" 2>/dev/null; then
remove_line "$candidate" "$pattern" "$label"
found=true
fi
done
if [[ "$found" == false ]]; then
noop "$label in $dir/{.,}bunfig.toml"
fi
}
# ─── Confirmation prompt ─────────────────────────────────────────────
confirm() {
local action="$1"
local bunfig="$2"
local yarnrc="$3"
echo ""
echo "══════════════════════════════════════════════════"
echo " JS Package Manager Supply Chain Hardening"
echo "══════════════════════════════════════════════════"
echo " Action: $action"
echo " Target: $TARGET_DIR"
echo ""
echo " Files that will be modified:"
echo " * $TARGET_DIR/.npmrc"
echo " * $bunfig"
echo " * $PNPM_RC"
echo " * $yarnrc"
echo ""
if [[ "$action" == "Apply" ]]; then
echo " Settings to apply:"
echo " * ${RELEASE_AGE_DAYS}-day minimum release age (npm, bun, pnpm, yarn)"
else
echo " All settings added by this script will be removed."
echo " Other content in these files will not be touched."
fi
echo ""
if [[ "$SKIP_CONFIRM" == true ]]; then
return 0
fi
printf " Proceed? [y/N] "
read -r reply
case "$reply" in
[yY]|[yY][eE][sS]) return 0 ;;
*)
echo " Aborted."
exit 0
;;
esac
}
# ─── Undo mode ───────────────────────────────────────────────────────
do_undo() {
confirm "Undo" "$TARGET_DIR/{.,}bunfig.toml" "$TARGET_DIR/.yarnrc.yml"
echo ""
# npm
NPMRC="$TARGET_DIR/.npmrc"
echo "── npm ──"
remove_line "$NPMRC" "min-release-age" "min-release-age"
echo ""
# Bun — check both dotfile and plain variants
echo "── Bun ──"
remove_bunfig_line "$TARGET_DIR" "minimumReleaseAge" "minimumReleaseAge"
# Clean up empty [install] sections in both variants
for candidate in "$TARGET_DIR/.bunfig.toml" "$TARGET_DIR/bunfig.toml"; do
if [[ -f "$candidate" ]] && grep -qF "[install]" "$candidate" 2>/dev/null; then
local after_header
after_header=$(sed -n '/\[install\]/,$ p' "$candidate" | tail -n +2 | grep -c '[^[:space:]]' || true)
if [[ "$after_header" -eq 0 ]]; then
remove_line "$candidate" "[install]" "[install] section (now empty)"
fi
fi
done
echo ""
# pnpm
echo "── pnpm ──"
remove_line "$PNPM_RC" "minimum-release-age" "minimum-release-age"
echo ""
# Yarn — check .yarnrc.yml only (Yarn Classic .yarnrc doesn't support this)
YARNRC="$TARGET_DIR/.yarnrc.yml"
echo "── Yarn 4 ──"
remove_line "$YARNRC" "npmMinimalAgeGate" "npmMinimalAgeGate"
echo ""
echo "══════════════════════════════════════════════════"
echo " Undo complete."
echo "══════════════════════════════════════════════════"
}
# ─── Apply mode ──────────────────────────────────────────────────────
do_apply() {
# Resolve file paths before confirmation so we can show them
BUNFIG="$(resolve_bunfig "$TARGET_DIR")"
YARNRC="$(resolve_yarnrc "$TARGET_DIR")"
confirm "Apply" "$BUNFIG" "$YARNRC"
echo ""
# npm (.npmrc) — always .npmrc, no variants
NPMRC="$TARGET_DIR/.npmrc"
echo "── npm ──"
ensure_line "$NPMRC" "min-release-age" \
"min-release-age=${RELEASE_AGE_DAYS}" \
"min-release-age (${RELEASE_AGE_DAYS}d)"
echo ""
# Bun — .bunfig.toml (global) or bunfig.toml (project)
RELEASE_AGE_SECONDS=$((RELEASE_AGE_DAYS * 86400))
echo "── Bun ──"
touch "$BUNFIG"
if grep -qF "minimumReleaseAge" "$BUNFIG" 2>/dev/null; then
skip "minimumReleaseAge in $BUNFIG"
else
if ! grep -qF "[install]" "$BUNFIG" 2>/dev/null; then
printf '\n[install]\n' >> "$BUNFIG"
fi
printf 'minimumReleaseAge = %d # %d days in seconds\n' \
"$RELEASE_AGE_SECONDS" "$RELEASE_AGE_DAYS" >> "$BUNFIG"
log "minimumReleaseAge (${RELEASE_AGE_SECONDS}s) → $BUNFIG"
fi
echo ""
# pnpm (platform-specific global rc)
RELEASE_AGE_MINUTES=$((RELEASE_AGE_DAYS * 1440))
echo "── pnpm ──"
ensure_line "$PNPM_RC" "minimum-release-age" \
"minimum-release-age=${RELEASE_AGE_MINUTES}" \
"minimum-release-age (${RELEASE_AGE_MINUTES}m)"
echo ""
# Yarn 4 (.yarnrc.yml) — resolve_yarnrc already warned if Yarn Classic
echo "── Yarn 4 ──"
ensure_line "$YARNRC" "npmMinimalAgeGate" \
"npmMinimalAgeGate: \"${RELEASE_AGE_DAYS}d\"" \
"npmMinimalAgeGate (${RELEASE_AGE_DAYS}d)"
echo ""
echo "══════════════════════════════════════════════════"
echo " Done. Run '$(basename "$0") --help' for caveats."
echo " Run '$(basename "$0") --undo' to reverse changes."
echo "══════════════════════════════════════════════════"
}
# ─── Entrypoint ──────────────────────────────────────────────────────
if [[ "$UNDO" == true ]]; then
do_undo
else
do_apply
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment