Last active
April 9, 2026 09:45
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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