Last active
May 20, 2026 14:47
-
-
Save steig/ca5d31670d1ae3c9e444875272baa3f6 to your computer and use it in GitHub Desktop.
Audit-only supply-chain hardening check for dev machines (npm/pnpm/pip/uv/SSH/VS Code/brew)
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 | |
| # dev-supply-chain-audit.sh — developer machine supply-chain hardening audit | |
| # | |
| # Audit-only. Reports findings against current attack vectors and prints the | |
| # command needed to remediate each one. Makes no changes. | |
| # | |
| # Usage: | |
| # curl -fsSL <raw-url> | bash | |
| # bash dev-supply-chain-audit.sh # full audit, human output | |
| # bash dev-supply-chain-audit.sh --json # one JSON object per finding | |
| # bash dev-supply-chain-audit.sh --quiet # only show fails | |
| # bash dev-supply-chain-audit.sh --only NPM-001 # run a single rule | |
| # bash dev-supply-chain-audit.sh --skip GH-001 # skip rule(s) (comma-sep) | |
| # bash dev-supply-chain-audit.sh --list # list all rule IDs | |
| # bash dev-supply-chain-audit.sh --version | |
| # | |
| # Exit codes: 0 = clean, 1 = warnings only, 2 = any high/critical failures. | |
| # | |
| # Verify before you `curl | bash`: | |
| # curl -fsSL <raw-url> -o /tmp/audit.sh | |
| # shasum -a 256 /tmp/audit.sh # compare to the SHA256 published with the gist | |
| # bash /tmp/audit.sh | |
| # | |
| # License: do whatever. No warranty. | |
| set -u | |
| VERSION="0.2.0" | |
| # ---------- argv ------------------------------------------------------------ | |
| JSON_MODE=0 | |
| QUIET=0 | |
| ONLY="" | |
| SKIP="" | |
| LIST=0 | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --json) JSON_MODE=1; shift ;; | |
| --quiet|-q) QUIET=1; shift ;; | |
| --only) ONLY="$2"; shift 2 ;; | |
| --skip) SKIP="$2"; shift 2 ;; | |
| --list) LIST=1; shift ;; | |
| --version) echo "dev-supply-chain-audit.sh $VERSION"; exit 0 ;; | |
| --help|-h) | |
| sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' | |
| exit 0 ;; | |
| *) echo "unknown arg: $1" >&2; exit 64 ;; | |
| esac | |
| done | |
| # ---------- output helpers -------------------------------------------------- | |
| if [[ -t 1 && $JSON_MODE -eq 0 ]]; then | |
| C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_GRN=$'\033[32m'; C_MAG=$'\033[35m' | |
| C_CYA=$'\033[36m'; C_BLD=$'\033[1m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m' | |
| else | |
| C_RED=; C_YEL=; C_GRN=; C_MAG=; C_CYA=; C_BLD=; C_DIM=; C_RST= | |
| fi | |
| CRIT_COUNT=0 | |
| HIGH_COUNT=0 | |
| MED_COUNT=0 | |
| LOW_COUNT=0 | |
| PASS_COUNT=0 | |
| INFO_COUNT=0 | |
| # ---------- rule registry -------------------------------------------------- | |
| # Each rule has an ID like CAT-NNN. Map ID -> default-severity / title. | |
| declare -A RULE_TITLE=() | |
| declare -A RULE_SEV=() # default severity if it fails | |
| declare -A RULE_GROUP=() | |
| register_rule() { | |
| local id="$1" sev="$2" group="$3" title="$4" | |
| RULE_TITLE[$id]="$title" | |
| RULE_SEV[$id]="$sev" | |
| RULE_GROUP[$id]="$group" | |
| } | |
| rule_enabled() { | |
| local id="$1" | |
| if [[ -n "$ONLY" ]]; then | |
| [[ ",$ONLY," == *",$id,"* ]] && return 0 || return 1 | |
| fi | |
| if [[ -n "$SKIP" ]]; then | |
| [[ ",$SKIP," == *",$id,"* ]] && return 1 | |
| fi | |
| return 0 | |
| } | |
| # Emit a finding. Args: id status[pass|fail|warn|info] severity message [fix] | |
| emit() { | |
| local id="$1" status="$2" sev="$3" msg="$4" fix="${5:-}" | |
| case "$status" in | |
| pass) PASS_COUNT=$((PASS_COUNT+1)) ;; | |
| info) INFO_COUNT=$((INFO_COUNT+1)) ;; | |
| warn) MED_COUNT=$((MED_COUNT+1)) ;; | |
| fail) | |
| case "$sev" in | |
| critical) CRIT_COUNT=$((CRIT_COUNT+1)) ;; | |
| high) HIGH_COUNT=$((HIGH_COUNT+1)) ;; | |
| medium) MED_COUNT=$((MED_COUNT+1)) ;; | |
| low) LOW_COUNT=$((LOW_COUNT+1)) ;; | |
| esac | |
| ;; | |
| esac | |
| if [[ $JSON_MODE -eq 1 ]]; then | |
| # JSON-escape strings using printf+sed (no jq dep) | |
| j_esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g; s/\n/\\n/g'; } | |
| printf '{"rule":"%s","status":"%s","severity":"%s","group":"%s","message":"%s","fix":"%s"}\n' \ | |
| "$id" "$status" "$sev" "${RULE_GROUP[$id]:-}" \ | |
| "$(j_esc "$msg")" "$(j_esc "$fix")" | |
| return | |
| fi | |
| if [[ $QUIET -eq 1 && "$status" != fail ]]; then return; fi | |
| local glyph color | |
| case "$status" in | |
| pass) glyph='✓'; color=$C_GRN ;; | |
| info) glyph='·'; color=$C_DIM ;; | |
| warn) glyph='!'; color=$C_YEL ;; | |
| fail) | |
| case "$sev" in | |
| critical) glyph='✗'; color=$C_MAG ;; | |
| high) glyph='✗'; color=$C_RED ;; | |
| medium) glyph='!'; color=$C_YEL ;; | |
| low) glyph='·'; color=$C_DIM ;; | |
| esac | |
| ;; | |
| esac | |
| printf ' %s%s%s [%s] %s\n' "$color" "$glyph" "$C_RST" "$id" "$msg" | |
| [[ -n "$fix" ]] && printf ' %sfix:%s %s\n' "$C_DIM" "$C_RST" "$fix" | |
| } | |
| section() { | |
| [[ $JSON_MODE -eq 1 ]] && return | |
| [[ $QUIET -eq 1 ]] && return | |
| printf '\n%s━━━ %s ━━━%s\n' "$C_CYA$C_BLD" "$1" "$C_RST" | |
| } | |
| # ---------- platform / portable stat --------------------------------------- | |
| OS="$(uname -s)" | |
| case "$OS" in | |
| Darwin) PLATFORM=macos ;; | |
| Linux) PLATFORM=linux ;; | |
| *) PLATFORM=unknown ;; | |
| esac | |
| if stat --version >/dev/null 2>&1; then | |
| stat_mode() { stat -c '%a' "$1" 2>/dev/null; } | |
| else | |
| stat_mode() { stat -f '%Lp' "$1" 2>/dev/null; } | |
| fi | |
| have() { command -v "$1" >/dev/null 2>&1; } | |
| # ============================================================================ | |
| # Rule registrations (so --list works without running checks) | |
| # ============================================================================ | |
| register_rule NPM-001 high "npm/pnpm" "ignore-scripts blocks postinstall execution" | |
| register_rule NPM-002 high "npm/pnpm" "npm minimum-release-age quarantine (npm 11.6+)" | |
| register_rule NPM-003 high "npm/pnpm" "pnpm minimum-release-age quarantine" | |
| register_rule NPM-004 medium "npm/pnpm" "no plaintext _authToken in ~/.npmrc" | |
| register_rule NPM-005 high "npm/pnpm" "npm audit clean (current directory)" | |
| register_rule NPM-006 high "npm/pnpm" "pnpm audit clean (current directory)" | |
| register_rule PIP-001 medium "python" "pip require-virtualenv" | |
| register_rule PIP-002 low "python" "uv config present" | |
| register_rule PIP-003 high "python" "pip-audit clean (current directory)" | |
| register_rule FS-001 critical "secrets" "no world-readable secrets in ~/.config / ~/.aws / etc" | |
| register_rule FS-002 high "secrets" ".env files in cwd are gitignored" | |
| register_rule FS-003 high "secrets" "SSH private key perms are 600/400" | |
| register_rule SSH-001 medium "ssh" "no weak (<3072-bit) RSA keys" | |
| register_rule SSH-002 medium "ssh" "no global ForwardAgent=yes" | |
| register_rule VSC-001 high "editor" "VS Code / Cursor extension auto-update disabled" | |
| register_rule VSC-002 medium "editor" "VS Code / Cursor extensions from recognized publishers" | |
| register_rule BREW-001 medium "homebrew" "no greedy casks" | |
| register_rule BREW-002 low "homebrew" "no taps from unknown orgs" | |
| register_rule BROWSER-001 medium "browser" "browser extensions inventoried" | |
| register_rule MCP-001 high "claude" "MCP servers in ~/.claude.json reviewed" | |
| register_rule GH-001 medium "github" "gh CLI token scopes are minimal" | |
| register_rule OS-001 high "os" "FileVault enabled (macOS)" | |
| register_rule OS-002 high "os" "Gatekeeper enabled (macOS)" | |
| register_rule OS-003 medium "os" "SIP enabled (macOS)" | |
| register_rule OS-004 medium "os" "Firewall enabled" | |
| register_rule CI-001 high "ci" ".github/workflows actions pinned to SHA" | |
| register_rule CI-002 critical "ci" "no pull_request_target + checkout(ref:head.sha)" | |
| register_rule HIST-001 medium "shell" "no obvious secrets in shell history" | |
| register_rule GIT-001 low "git" "pre-commit / gitleaks installed in repo" | |
| register_rule DOCKER-001 medium "docker" "no insecure-registries in docker daemon config" | |
| # ---------- --list shortcut ------------------------------------------------- | |
| if [[ $LIST -eq 1 ]]; then | |
| printf '%-15s %-10s %-10s %s\n' "ID" "SEVERITY" "GROUP" "TITLE" | |
| for id in "${!RULE_TITLE[@]}"; do | |
| printf '%-15s %-10s %-10s %s\n' "$id" "${RULE_SEV[$id]}" "${RULE_GROUP[$id]}" "${RULE_TITLE[$id]}" | |
| done | sort | |
| exit 0 | |
| fi | |
| # ---------- banner ---------------------------------------------------------- | |
| if [[ $JSON_MODE -eq 0 && $QUIET -eq 0 ]]; then | |
| printf '%s%sDeveloper Supply-Chain Audit v%s%s\n' "$C_BLD" "$C_CYA" "$VERSION" "$C_RST" | |
| printf 'platform=%s date=%s host=%s pwd=%s\n' \ | |
| "$PLATFORM" "$(date +%Y-%m-%d)" "$(hostname -s 2>/dev/null || hostname)" "$(pwd)" | |
| fi | |
| # ============================================================================ | |
| # NPM / PNPM | |
| # ============================================================================ | |
| section "npm / pnpm" | |
| NPMRC="$HOME/.npmrc" | |
| PNPM_RC="$HOME/.config/pnpm/rc" | |
| NPMRC_CONTENT=""; PNPM_RC_CONTENT="" | |
| [[ -f "$NPMRC" ]] && NPMRC_CONTENT="$(cat "$NPMRC")" | |
| [[ -f "$PNPM_RC" ]] && PNPM_RC_CONTENT="$(cat "$PNPM_RC")" | |
| if rule_enabled NPM-001; then | |
| if echo "$NPMRC_CONTENT" | grep -qE '^\s*ignore-scripts\s*=\s*true'; then | |
| emit NPM-001 pass high "ignore-scripts=true in ~/.npmrc" | |
| else | |
| emit NPM-001 fail high "ignore-scripts not enabled — postinstall hooks will run" \ | |
| "echo 'ignore-scripts=true' >> ~/.npmrc" | |
| fi | |
| fi | |
| if rule_enabled NPM-002; then | |
| age="$(echo "$NPMRC_CONTENT" | grep -oE 'minimum-release-age\s*=\s*[0-9]+' | grep -oE '[0-9]+' | head -1)" | |
| if [[ -n "${age:-}" && "$age" -ge 1440 ]]; then | |
| emit NPM-002 pass high "npm minimum-release-age=${age} min ($(( age / 60 ))h)" | |
| else | |
| emit NPM-002 fail high "npm has no release-age quarantine (need 1440+ min)" \ | |
| "echo 'minimum-release-age=4320' >> ~/.npmrc # 3 days, requires npm 11.6+" | |
| fi | |
| fi | |
| if rule_enabled NPM-003; then | |
| age="$(echo "$PNPM_RC_CONTENT" | grep -oE 'minimum-release-age\s*=\s*[0-9]+' | grep -oE '[0-9]+' | head -1)" | |
| if have pnpm; then | |
| if [[ -n "${age:-}" && "$age" -ge 1440 ]]; then | |
| emit NPM-003 pass high "pnpm minimum-release-age=${age} min ($(( age / 60 ))h)" | |
| else | |
| emit NPM-003 fail high "pnpm has no release-age quarantine" \ | |
| "mkdir -p ~/.config/pnpm && echo 'minimum-release-age=4320' >> ~/.config/pnpm/rc" | |
| fi | |
| else | |
| emit NPM-003 info low "pnpm not installed — skipping" | |
| fi | |
| fi | |
| if rule_enabled NPM-004; then | |
| if echo "$NPMRC_CONTENT" | grep -qE '_authToken\s*=\s*[A-Za-z0-9_-]{20,}\s*$'; then | |
| emit NPM-004 fail medium "~/.npmrc contains a plaintext npm token" \ | |
| "Move token out of file; reference as \${NPM_TOKEN} and export from a 600-perm secret file" | |
| else | |
| emit NPM-004 pass medium "no plaintext npm token in ~/.npmrc" | |
| fi | |
| fi | |
| # CVE checks via npm/pnpm audit on the current directory | |
| if rule_enabled NPM-005; then | |
| if [[ -f "$PWD/package.json" && -f "$PWD/package-lock.json" ]] && have npm; then | |
| if out="$(npm audit --json 2>/dev/null)"; then | |
| if have jq; then | |
| crit=$(echo "$out" | jq -r '.metadata.vulnerabilities.critical // 0') | |
| high=$(echo "$out" | jq -r '.metadata.vulnerabilities.high // 0') | |
| mod=$(echo "$out" | jq -r '.metadata.vulnerabilities.moderate // 0') | |
| low=$(echo "$out" | jq -r '.metadata.vulnerabilities.low // 0') | |
| else | |
| crit=$(echo "$out" | grep -oE '"critical":[0-9]+' | head -1 | grep -oE '[0-9]+') | |
| high=$(echo "$out" | grep -oE '"high":[0-9]+' | head -1 | grep -oE '[0-9]+') | |
| mod=$(echo "$out" | grep -oE '"moderate":[0-9]+' | head -1 | grep -oE '[0-9]+') | |
| low=$(echo "$out" | grep -oE '"low":[0-9]+' | head -1 | grep -oE '[0-9]+') | |
| fi | |
| total=$(( ${crit:-0} + ${high:-0} + ${mod:-0} + ${low:-0} )) | |
| if [[ $total -eq 0 ]]; then | |
| emit NPM-005 pass high "npm audit: 0 vulnerabilities in $(basename "$PWD")" | |
| else | |
| sev=medium | |
| [[ ${high:-0} -gt 0 ]] && sev=high | |
| [[ ${crit:-0} -gt 0 ]] && sev=critical | |
| emit NPM-005 fail "$sev" \ | |
| "npm audit: ${crit:-0} critical / ${high:-0} high / ${mod:-0} mod / ${low:-0} low" \ | |
| "npm audit fix # auto-fix safe ones; review breaking changes manually" | |
| fi | |
| else | |
| emit NPM-005 info low "npm audit failed to run (offline or registry blocked)" | |
| fi | |
| else | |
| emit NPM-005 info low "no package-lock.json in cwd — skipping npm CVE scan" | |
| fi | |
| fi | |
| if rule_enabled NPM-006; then | |
| if [[ -f "$PWD/pnpm-lock.yaml" ]] && have pnpm; then | |
| if out="$(pnpm audit --json 2>/dev/null)"; then | |
| if have jq; then | |
| crit=$(echo "$out" | jq -r '.metadata.vulnerabilities.critical // 0' 2>/dev/null || echo 0) | |
| high=$(echo "$out" | jq -r '.metadata.vulnerabilities.high // 0' 2>/dev/null || echo 0) | |
| mod=$(echo "$out" | jq -r '.metadata.vulnerabilities.moderate // 0' 2>/dev/null || echo 0) | |
| low=$(echo "$out" | jq -r '.metadata.vulnerabilities.low // 0' 2>/dev/null || echo 0) | |
| else | |
| crit=$(echo "$out" | grep -oE '"critical":[0-9]+' | head -1 | grep -oE '[0-9]+') | |
| high=$(echo "$out" | grep -oE '"high":[0-9]+' | head -1 | grep -oE '[0-9]+') | |
| mod=$(echo "$out" | grep -oE '"moderate":[0-9]+' | head -1 | grep -oE '[0-9]+') | |
| low=$(echo "$out" | grep -oE '"low":[0-9]+' | head -1 | grep -oE '[0-9]+') | |
| fi | |
| total=$(( ${crit:-0} + ${high:-0} + ${mod:-0} + ${low:-0} )) | |
| if [[ $total -eq 0 ]]; then | |
| emit NPM-006 pass high "pnpm audit: 0 vulnerabilities" | |
| else | |
| sev=medium | |
| [[ ${high:-0} -gt 0 ]] && sev=high | |
| [[ ${crit:-0} -gt 0 ]] && sev=critical | |
| emit NPM-006 fail "$sev" \ | |
| "pnpm audit: ${crit:-0} crit / ${high:-0} high / ${mod:-0} mod / ${low:-0} low" \ | |
| "pnpm update --interactive # bump vulnerable deps" | |
| fi | |
| fi | |
| else | |
| emit NPM-006 info low "no pnpm-lock.yaml in cwd — skipping pnpm CVE scan" | |
| fi | |
| fi | |
| # ============================================================================ | |
| # PIP / UV / PYTHON | |
| # ============================================================================ | |
| section "Python (pip / uv)" | |
| PIP_CONF="$HOME/.config/pip/pip.conf" | |
| [[ "$PLATFORM" == macos ]] && PIP_CONF_MAC="$HOME/Library/Application Support/pip/pip.conf" | |
| UV_TOML="$HOME/.config/uv/uv.toml" | |
| if rule_enabled PIP-001; then | |
| pip_conf_text="" | |
| [[ -f "$PIP_CONF" ]] && pip_conf_text+="$(cat "$PIP_CONF")" | |
| [[ "${PIP_CONF_MAC:-}" && -f "$PIP_CONF_MAC" ]] && pip_conf_text+="$(cat "$PIP_CONF_MAC")" | |
| if echo "$pip_conf_text" | grep -qE '^\s*require-virtualenv\s*=\s*(true|yes|1)'; then | |
| emit PIP-001 pass medium "pip require-virtualenv enabled" | |
| else | |
| emit PIP-001 fail medium "pip will install into system Python without a venv" \ | |
| "mkdir -p ~/.config/pip && printf '[global]\nrequire-virtualenv = true\n' >> ~/.config/pip/pip.conf" | |
| fi | |
| fi | |
| if rule_enabled PIP-002; then | |
| if [[ -f "$UV_TOML" ]]; then | |
| emit PIP-002 pass low "uv.toml present" | |
| elif have uv; then | |
| emit PIP-002 fail low "uv installed but no ~/.config/uv/uv.toml" \ | |
| "mkdir -p ~/.config/uv && printf '[pip]\nindex-url = \"https://pypi.org/simple\"\n' >> ~/.config/uv/uv.toml" | |
| else | |
| emit PIP-002 info low "uv not installed" | |
| fi | |
| fi | |
| if rule_enabled PIP-003; then | |
| scan_target="" | |
| [[ -f "$PWD/requirements.txt" ]] && scan_target="-r $PWD/requirements.txt" | |
| [[ -f "$PWD/pyproject.toml" ]] && scan_target="$scan_target" | |
| [[ -f "$PWD/uv.lock" ]] && scan_target="$scan_target" | |
| if [[ -n "$scan_target" ]]; then | |
| if have pip-audit; then | |
| if pip-audit $scan_target -f json >/tmp/pip-audit.$$ 2>/dev/null; then | |
| if have jq; then | |
| vuln=$(jq -r '.dependencies // [] | map(.vulns | length) | add // 0' /tmp/pip-audit.$$ 2>/dev/null) | |
| else | |
| vuln=$(grep -c '"id":' /tmp/pip-audit.$$ 2>/dev/null || echo 0) | |
| fi | |
| if [[ "${vuln:-0}" -eq 0 ]]; then | |
| emit PIP-003 pass high "pip-audit: 0 vulnerabilities" | |
| else | |
| emit PIP-003 fail high "pip-audit: $vuln vulnerable dependency findings" \ | |
| "pip-audit --fix # or upgrade pinned versions manually" | |
| fi | |
| else | |
| emit PIP-003 info low "pip-audit run failed" | |
| fi | |
| rm -f /tmp/pip-audit.$$ | |
| else | |
| emit PIP-003 info low "pip-audit not installed (recommend: pipx install pip-audit)" | |
| fi | |
| else | |
| emit PIP-003 info low "no Python project in cwd — skipping" | |
| fi | |
| fi | |
| # ============================================================================ | |
| # SECRETS / FILESYSTEM | |
| # ============================================================================ | |
| section "Secrets & filesystem" | |
| if rule_enabled FS-001; then | |
| declare -a SECRET_GLOBS=( | |
| "$HOME/.config/gcloud/*.json" | |
| "$HOME/.config/gcloud/*.db" | |
| "$HOME/.config/gws/*" | |
| "$HOME/.config/*/credentials*" | |
| "$HOME/.config/*/client_secret*" | |
| "$HOME/.config/*/token*" | |
| "$HOME/.config/*/*.pem" | |
| "$HOME/.config/*/*.key" | |
| "$HOME/.aws/credentials" | |
| "$HOME/.aws/config" | |
| "$HOME/.docker/config.json" | |
| "$HOME/.netrc" | |
| "$HOME/.pgpass" | |
| "$HOME/.kube/config" | |
| ) | |
| declare -A seen=() | |
| found=0 | |
| for pat in "${SECRET_GLOBS[@]}"; do | |
| for f in $pat; do | |
| [[ -f "$f" ]] || continue | |
| [[ -n "${seen[$f]:-}" ]] && continue | |
| seen[$f]=1 | |
| mode="$(stat_mode "$f")" | |
| [[ -z "$mode" ]] && continue | |
| go_perm="${mode:${#mode}-2:2}" | |
| if [[ "$go_perm" != "00" ]]; then | |
| emit FS-001 fail critical "${f/#$HOME/~} mode=$mode (should be 600/400)" \ | |
| "chmod 600 \"$f\"" | |
| found=$((found+1)) | |
| fi | |
| done | |
| done | |
| [[ $found -eq 0 ]] && emit FS-001 pass critical "no group/world-readable secret files in standard locations" | |
| fi | |
| if rule_enabled FS-002; then | |
| if [[ -d "$PWD/.git" ]]; then | |
| leaked=() | |
| findings=0 | |
| for env in "$PWD"/.env "$PWD"/.env.local "$PWD"/.env.*; do | |
| [[ -f "$env" ]] || continue | |
| relpath="${env#$PWD/}" | |
| # Skip templates: .env.example, .env.sample, .env.template (intentionally committed) | |
| case "$relpath" in | |
| .env.example|.env.sample|.env.template|.env.dist|.env.defaults) continue ;; | |
| esac | |
| if ! git -C "$PWD" check-ignore -q "$env" 2>/dev/null; then | |
| if ! git -C "$PWD" ls-files --error-unmatch "$relpath" >/dev/null 2>&1; then | |
| leaked+=("$relpath") | |
| findings=$((findings+1)) | |
| else | |
| emit FS-002 fail critical "$relpath is COMMITTED to git" \ | |
| "git rm --cached \"$relpath\" && echo \"$relpath\" >> .gitignore && git commit" | |
| findings=$((findings+1)) | |
| fi | |
| fi | |
| done | |
| if [[ ${#leaked[@]} -gt 0 ]]; then | |
| for f in "${leaked[@]}"; do | |
| emit FS-002 fail high "$f is untracked but not gitignored (one wrong 'git add .' away from leak)" \ | |
| "echo '$f' >> .gitignore" | |
| done | |
| fi | |
| [[ $findings -eq 0 ]] && emit FS-002 pass high ".env files in cwd are gitignored, templates, or absent" | |
| else | |
| emit FS-002 info low "cwd is not a git repo — skipping" | |
| fi | |
| fi | |
| if rule_enabled FS-003; then | |
| ssh_bad=0 | |
| if [[ -d "$HOME/.ssh" ]]; then | |
| for f in "$HOME"/.ssh/id_* "$HOME"/.ssh/*.pem; do | |
| [[ -f "$f" ]] || continue | |
| [[ "$f" == *.pub ]] && continue | |
| mode="$(stat_mode "$f")" | |
| [[ -z "$mode" ]] && continue | |
| if [[ "$mode" != "600" && "$mode" != "400" ]]; then | |
| emit FS-003 fail high "~/.ssh/${f##*/} mode=$mode (should be 600)" \ | |
| "chmod 600 \"$f\"" | |
| ssh_bad=$((ssh_bad+1)) | |
| fi | |
| done | |
| fi | |
| [[ $ssh_bad -eq 0 ]] && emit FS-003 pass high "all SSH private keys have safe perms" | |
| fi | |
| # ============================================================================ | |
| # SSH | |
| # ============================================================================ | |
| section "SSH" | |
| if rule_enabled SSH-001; then | |
| if [[ -f "$HOME/.ssh/id_rsa.pub" ]] && have ssh-keygen; then | |
| bits="$(ssh-keygen -lf "$HOME/.ssh/id_rsa.pub" 2>/dev/null | awk '{print $1}')" | |
| if [[ -n "$bits" && "$bits" -lt 3072 ]]; then | |
| emit SSH-001 fail medium "id_rsa is ${bits}-bit RSA (weak)" \ | |
| "ssh-keygen -t ed25519 -C \"\$(whoami)@\$(hostname)\" # migrate and delete id_rsa" | |
| else | |
| emit SSH-001 pass medium "id_rsa is ${bits}-bit RSA (acceptable)" | |
| fi | |
| else | |
| emit SSH-001 pass medium "no legacy id_rsa" | |
| fi | |
| fi | |
| if rule_enabled SSH-002; then | |
| if grep -rEq '^\s*ForwardAgent\s+yes' "$HOME/.ssh/config" "$HOME/.ssh/config.d" 2>/dev/null; then | |
| # accept it if scoped per-host, fail only if unconditional `Host *` | |
| if grep -B1 -E '^\s*ForwardAgent\s+yes' "$HOME/.ssh/config" 2>/dev/null | grep -qE '^\s*Host\s+\*\s*$'; then | |
| emit SSH-002 fail medium "ForwardAgent yes is set on Host * (any compromised host can sign with your keys)" \ | |
| "Scope ForwardAgent to specific hosts only, or remove it" | |
| else | |
| emit SSH-002 pass medium "ForwardAgent is scoped per-host" | |
| fi | |
| else | |
| emit SSH-002 pass medium "ForwardAgent not enabled" | |
| fi | |
| fi | |
| # ============================================================================ | |
| # VS CODE / CURSOR | |
| # ============================================================================ | |
| section "VS Code / Cursor" | |
| declare -a CODE_BINS=() | |
| have code && CODE_BINS+=("code") | |
| have cursor && CODE_BINS+=("cursor") | |
| if [[ ${#CODE_BINS[@]} -eq 0 ]]; then | |
| emit VSC-001 info low "neither VS Code nor Cursor found" | |
| else | |
| trusted_regex='^(ms-|github\.|anthropic\.|openai\.|hashicorp\.|golang\.|rust-lang\.|redhat\.|esbenp\.|dbaeumer\.|charliermarsh\.|bradlc\.|eamodio\.|editorconfig\.|bbenoist\.|enkia\.|dracula-theme\.|bierner\.|davidanson\.|yzhang\.|christian-kohler\.|alefragnani\.|gruntfuggly\.|shopify\.|mtxr\.|mechatroner\.|tomoki1207\.|jebbs\.)' | |
| for bin in "${CODE_BINS[@]}"; do | |
| case "$PLATFORM" in | |
| macos) | |
| cap_name="$(echo "$bin" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')" | |
| SETTINGS="$HOME/Library/Application Support/$cap_name/User/settings.json" ;; | |
| linux) | |
| cap_name="$(echo "$bin" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')" | |
| SETTINGS="$HOME/.config/$cap_name/User/settings.json" ;; | |
| esac | |
| if rule_enabled VSC-001; then | |
| if [[ -f "$SETTINGS" ]] && grep -qE '"extensions\.autoUpdate"\s*:\s*false' "$SETTINGS"; then | |
| emit VSC-001 pass high "$bin: auto-update disabled" | |
| else | |
| emit VSC-001 fail high "$bin: extensions auto-update silently (no review on publisher takeover)" \ | |
| "Add \"extensions.autoUpdate\": false and \"extensions.autoCheckUpdates\": false to $SETTINGS" | |
| fi | |
| fi | |
| if rule_enabled VSC-002; then | |
| if exts="$("$bin" --list-extensions 2>/dev/null)"; then | |
| flagged=() | |
| while IFS= read -r ext; do | |
| [[ -z "$ext" ]] && continue | |
| if ! echo "$ext" | grep -qE "$trusted_regex"; then | |
| flagged+=("$ext") | |
| fi | |
| done <<< "$exts" | |
| if [[ ${#flagged[@]} -eq 0 ]]; then | |
| emit VSC-002 pass medium "$bin: all extensions from recognized publishers" | |
| else | |
| for ext in "${flagged[@]}"; do | |
| emit VSC-002 fail medium "$bin: unverified publisher: $ext" \ | |
| "Audit at https://marketplace.visualstudio.com/items?itemName=$ext or remove: $bin --uninstall-extension $ext" | |
| done | |
| fi | |
| fi | |
| fi | |
| done | |
| fi | |
| # ============================================================================ | |
| # BROWSER EXTENSIONS | |
| # ============================================================================ | |
| section "Browser extensions" | |
| if rule_enabled BROWSER-001; then | |
| declare -a BROWSER_PATHS=() | |
| if [[ "$PLATFORM" == macos ]]; then | |
| BROWSER_PATHS=( | |
| "$HOME/Library/Application Support/Google/Chrome/Default/Extensions" | |
| "$HOME/Library/Application Support/Google/Chrome/Profile 1/Extensions" | |
| "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Extensions" | |
| "$HOME/Library/Application Support/Arc/User Data/Default/Extensions" | |
| "$HOME/Library/Application Support/Zen/Profiles" | |
| "$HOME/Library/Application Support/Firefox/Profiles" | |
| "$HOME/Library/Application Support/Microsoft Edge/Default/Extensions" | |
| ) | |
| else | |
| BROWSER_PATHS=( | |
| "$HOME/.config/google-chrome/Default/Extensions" | |
| "$HOME/.config/BraveSoftware/Brave-Browser/Default/Extensions" | |
| "$HOME/.config/microsoft-edge/Default/Extensions" | |
| "$HOME/.mozilla/firefox" | |
| ) | |
| fi | |
| any_found=0 | |
| declare -A reported_ext=() | |
| for dir in "${BROWSER_PATHS[@]}"; do | |
| [[ -d "$dir" ]] || continue | |
| any_found=1 | |
| browser="$(echo "$dir" | awk -F/ '{for(i=1;i<=NF;i++) if($i~/Chrome|Brave|Arc|Zen|Firefox|Edge/) {print $i; exit}}')" | |
| risky=0 | |
| total=0 | |
| while IFS= read -r -d '' manifest; do | |
| total=$((total+1)) | |
| if grep -qE '"(<all_urls>|nativeMessaging|webRequest|webRequestBlocking|cookies|debugger|tabs|management)"' "$manifest" 2>/dev/null; then | |
| ext_id="$(echo "$manifest" | awk -F/ '{print $(NF-2)}')" | |
| # dedupe by (browser, ext_id) — multiple profiles install the same extension | |
| key="$browser:$ext_id" | |
| [[ -n "${reported_ext[$key]:-}" ]] && continue | |
| reported_ext[$key]=1 | |
| # Strip i18n placeholders like __MSG_appName__ and clean quotes | |
| name="$(python3 -c "import json,sys | |
| try: | |
| d=json.load(open(sys.argv[1])) | |
| n=d.get('name','?') | |
| if isinstance(n,str) and not n.startswith('__MSG'): | |
| print(n) | |
| else: | |
| print(d.get('short_name', sys.argv[1].split('/')[-3])) | |
| except Exception: | |
| print('?') | |
| " "$manifest" 2>/dev/null || echo "?")" | |
| emit BROWSER-001 fail medium "$browser ext '$name' ($ext_id) requests high-risk perms" \ | |
| "Review at chrome://extensions or about:addons; remove if not essential" | |
| risky=$((risky+1)) | |
| fi | |
| done < <(find "$dir" -name manifest.json -not -path '*node_modules*' -print0 2>/dev/null) | |
| if [[ $total -gt 0 && $risky -eq 0 ]]; then | |
| emit BROWSER-001 pass medium "$browser: $total extensions, none with high-risk perms" | |
| elif [[ $total -eq 0 ]]; then | |
| emit BROWSER-001 info low "$browser: no extensions" | |
| fi | |
| done | |
| [[ $any_found -eq 0 ]] && emit BROWSER-001 info low "no supported browsers found" | |
| fi | |
| # ============================================================================ | |
| # CLAUDE / MCP | |
| # ============================================================================ | |
| section "Claude Code / MCP servers" | |
| if rule_enabled MCP-001; then | |
| CLAUDE_JSON="$HOME/.claude.json" | |
| if [[ -f "$CLAUDE_JSON" ]]; then | |
| if have jq; then | |
| servers="$(jq -r '.mcpServers // {} | to_entries[] | "\(.key)\t\(.value.type // "stdio")\t\(.value.url // .value.command // "?")"' "$CLAUDE_JSON" 2>/dev/null)" | |
| else | |
| servers="$(grep -oE '"[a-zA-Z0-9_-]+":\s*\{[^}]+\}' "$CLAUDE_JSON" | head -20)" | |
| fi | |
| if [[ -z "$servers" ]]; then | |
| emit MCP-001 pass high "no MCP servers configured" | |
| else | |
| while IFS=$'\t' read -r name typ target; do | |
| [[ -z "$name" ]] && continue | |
| # Allowlist of known-safe MCP sources | |
| if echo "$target" | grep -qE '^(https://(api\.anthropic\.com|dash\.brain-ai\.dev|.*\.githubusercontent\.com)|^/nix/store/|^npx|^node\s)'; then | |
| emit MCP-001 pass high "MCP $name [$typ] → $target (recognized)" | |
| else | |
| emit MCP-001 fail high "MCP $name [$typ] → $target (unrecognized — verify you added it)" \ | |
| "Inspect ~/.claude.json; remove via: jq 'del(.mcpServers.\"$name\")' ~/.claude.json" | |
| fi | |
| done <<< "$servers" | |
| fi | |
| else | |
| emit MCP-001 info low "~/.claude.json not present" | |
| fi | |
| fi | |
| # ============================================================================ | |
| # GITHUB CLI | |
| # ============================================================================ | |
| section "GitHub CLI" | |
| if rule_enabled GH-001; then | |
| if have gh; then | |
| if gh auth status >/tmp/ghstat.$$ 2>&1; then | |
| scopes=$(grep -i 'scopes:' /tmp/ghstat.$$ | head -1 | sed 's/.*scopes://; s/ //g') | |
| if echo "$scopes" | grep -qE "delete_repo|admin:org|admin:enterprise|workflow"; then | |
| emit GH-001 fail medium "gh token has dangerous scopes: $scopes" \ | |
| "Reissue with minimal scopes: gh auth refresh --scopes repo,read:org" | |
| else | |
| emit GH-001 pass medium "gh token scopes: $scopes" | |
| fi | |
| else | |
| emit GH-001 info low "gh not logged in" | |
| fi | |
| rm -f /tmp/ghstat.$$ | |
| else | |
| emit GH-001 info low "gh CLI not installed" | |
| fi | |
| fi | |
| # ============================================================================ | |
| # OS BASELINE | |
| # ============================================================================ | |
| section "OS baseline" | |
| if [[ "$PLATFORM" == macos ]]; then | |
| if rule_enabled OS-001; then | |
| if have fdesetup; then | |
| if fdesetup status 2>/dev/null | grep -q "FileVault is On"; then | |
| emit OS-001 pass high "FileVault is on" | |
| else | |
| emit OS-001 fail high "FileVault is OFF — disk unencrypted at rest" \ | |
| "Enable in System Settings → Privacy & Security → FileVault" | |
| fi | |
| fi | |
| fi | |
| if rule_enabled OS-002; then | |
| if have spctl; then | |
| st="$(spctl --status 2>/dev/null)" | |
| if echo "$st" | grep -q "assessments enabled"; then | |
| emit OS-002 pass high "Gatekeeper enabled" | |
| else | |
| emit OS-002 fail high "Gatekeeper disabled — unsigned binaries can run silently" \ | |
| "sudo spctl --master-enable" | |
| fi | |
| fi | |
| fi | |
| if rule_enabled OS-003; then | |
| if have csrutil; then | |
| if csrutil status 2>/dev/null | grep -qi enabled; then | |
| emit OS-003 pass medium "SIP enabled" | |
| else | |
| emit OS-003 fail medium "SIP disabled — system file protections off" \ | |
| "Reboot to recovery; csrutil enable; reboot" | |
| fi | |
| fi | |
| fi | |
| if rule_enabled OS-004; then | |
| fw=/usr/libexec/ApplicationFirewall/socketfilterfw | |
| if [[ -x $fw ]]; then | |
| if $fw --getglobalstate 2>/dev/null | grep -q enabled; then | |
| emit OS-004 pass medium "Application Firewall enabled" | |
| else | |
| emit OS-004 fail medium "Application Firewall is OFF" \ | |
| "sudo $fw --setglobalstate on" | |
| fi | |
| fi | |
| fi | |
| elif [[ "$PLATFORM" == linux ]]; then | |
| if rule_enabled OS-004; then | |
| if have ufw; then | |
| if ufw status 2>/dev/null | grep -qi "Status: active"; then | |
| emit OS-004 pass medium "ufw active" | |
| else | |
| emit OS-004 fail medium "ufw is inactive" "sudo ufw enable" | |
| fi | |
| elif have firewall-cmd; then | |
| if firewall-cmd --state 2>/dev/null | grep -q running; then | |
| emit OS-004 pass medium "firewalld running" | |
| else | |
| emit OS-004 fail medium "firewalld is not running" "sudo systemctl enable --now firewalld" | |
| fi | |
| else | |
| emit OS-004 info low "no recognized firewall tool" | |
| fi | |
| fi | |
| fi | |
| # ============================================================================ | |
| # CI / GITHUB ACTIONS | |
| # ============================================================================ | |
| section "CI workflows" | |
| if [[ -d "$PWD/.github/workflows" ]]; then | |
| if rule_enabled CI-001; then | |
| declare -A seen_action=() | |
| unpinned=0 | |
| while IFS= read -r line; do | |
| action="${line#*uses:}" | |
| action="${action# }" | |
| action="${action%%[[:space:]]*}" | |
| # skip local actions and ones already pinned to a 40-char SHA | |
| [[ -z "$action" ]] && continue | |
| [[ "$action" == "./"* ]] && continue | |
| if echo "$action" | grep -qE '@[0-9a-f]{40}\b'; then continue; fi | |
| [[ -n "${seen_action[$action]:-}" ]] && continue | |
| seen_action[$action]=1 | |
| unpinned=$((unpinned+1)) | |
| emit CI-001 fail high "unpinned action: $action" \ | |
| "Replace tag with full SHA: gh api repos/$(echo "$action" | cut -d@ -f1)/commits/$(echo "$action" | cut -d@ -f2) -q .sha" | |
| done < <(grep -rhE '^\s*-?\s*uses:' "$PWD/.github/workflows" 2>/dev/null) | |
| [[ $unpinned -eq 0 ]] && emit CI-001 pass high "all action references are SHA-pinned (or local)" | |
| fi | |
| if rule_enabled CI-002; then | |
| danger=0 | |
| for wf in "$PWD"/.github/workflows/*.{yml,yaml}; do | |
| [[ -f "$wf" ]] || continue | |
| if grep -qE 'pull_request_target' "$wf" && grep -qE 'checkout.*ref.*head\.sha' "$wf"; then | |
| emit CI-002 fail critical "$wf has pull_request_target + checkout PR head SHA (RCE via PR)" \ | |
| "Drop pull_request_target unless you understand the threat model; never check out PR head with secrets" | |
| danger=$((danger+1)) | |
| fi | |
| done | |
| [[ $danger -eq 0 ]] && emit CI-002 pass critical "no dangerous pull_request_target + checkout patterns" | |
| fi | |
| else | |
| emit CI-001 info low "no .github/workflows in cwd" | |
| fi | |
| # ============================================================================ | |
| # SHELL HISTORY SECRETS | |
| # ============================================================================ | |
| section "Shell history" | |
| if rule_enabled HIST-001; then | |
| declare -a HIST_FILES=("$HOME/.zsh_history" "$HOME/.bash_history" "$HOME/.local/share/fish/fish_history") | |
| patterns='AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|ghs_[A-Za-z0-9]{30,}|sk-[A-Za-z0-9]{32,}|xoxb-[0-9]+-[0-9]+-[A-Za-z0-9]+|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' | |
| hits=0 | |
| for hf in "${HIST_FILES[@]}"; do | |
| [[ -f "$hf" ]] || continue | |
| if grep -cE "$patterns" "$hf" >/tmp/histhits.$$ 2>/dev/null; then | |
| count="$(cat /tmp/histhits.$$ 2>/dev/null)" | |
| if [[ "${count:-0}" -gt 0 ]]; then | |
| emit HIST-001 fail medium "$count probable-secret hit(s) in ${hf/#$HOME/~}" \ | |
| "Rotate any matching credential; trim history: grep -vE '<pattern>' $hf > $hf.clean && mv $hf.clean $hf" | |
| hits=$((hits+count)) | |
| fi | |
| fi | |
| done | |
| rm -f /tmp/histhits.$$ | |
| [[ $hits -eq 0 ]] && emit HIST-001 pass medium "no obvious secret patterns in shell history" | |
| fi | |
| # ============================================================================ | |
| # GIT / GITLEAKS | |
| # ============================================================================ | |
| section "Git / gitleaks" | |
| if rule_enabled GIT-001; then | |
| if [[ -d "$PWD/.git" ]]; then | |
| if [[ -f "$PWD/.pre-commit-config.yaml" ]] && grep -q gitleaks "$PWD/.pre-commit-config.yaml"; then | |
| if [[ -f "$PWD/.git/hooks/pre-commit" ]]; then | |
| emit GIT-001 pass low "gitleaks pre-commit configured AND installed" | |
| else | |
| emit GIT-001 fail low "gitleaks configured but hook not installed" \ | |
| "pre-commit install" | |
| fi | |
| else | |
| emit GIT-001 info low "no gitleaks pre-commit hook in this repo" | |
| fi | |
| fi | |
| fi | |
| # ============================================================================ | |
| # DOCKER | |
| # ============================================================================ | |
| section "Docker" | |
| if rule_enabled DOCKER-001; then | |
| for daemon in /etc/docker/daemon.json "$HOME/.docker/daemon.json"; do | |
| [[ -f "$daemon" ]] || continue | |
| if grep -qE '"insecure-registries"' "$daemon"; then | |
| registries="$(grep -A5 'insecure-registries' "$daemon" | tr -d ' \t' | grep -oE '"[^"]+\.[^"]+"' | head -5)" | |
| emit DOCKER-001 fail medium "$daemon has insecure-registries: $registries" \ | |
| "Remove insecure-registries; use TLS or local-network-only allowlists" | |
| else | |
| emit DOCKER-001 pass medium "$daemon has no insecure-registries" | |
| fi | |
| done | |
| fi | |
| # ============================================================================ | |
| # SUMMARY | |
| # ============================================================================ | |
| if [[ $JSON_MODE -eq 0 ]]; then | |
| printf '\n%s%sSummary%s\n' "$C_BLD" "$C_CYA" "$C_RST" | |
| printf ' %s✗ critical: %d%s %s✗ high: %d%s %s! medium: %d%s %s· low: %d%s %s✓ pass: %d%s %s· info: %d%s\n' \ | |
| "$C_MAG" "$CRIT_COUNT" "$C_RST" \ | |
| "$C_RED" "$HIGH_COUNT" "$C_RST" \ | |
| "$C_YEL" "$MED_COUNT" "$C_RST" \ | |
| "$C_DIM" "$LOW_COUNT" "$C_RST" \ | |
| "$C_GRN" "$PASS_COUNT" "$C_RST" \ | |
| "$C_DIM" "$INFO_COUNT" "$C_RST" | |
| cat <<'EOF' | |
| Reading findings: | |
| ✗ critical immediate exfil risk (world-readable keys, RCE-shaped CI) | |
| ✗ high attacker can pivot here within hours of disclosure | |
| ! medium needs fixing but not an active fire | |
| · low informational / hardening suggestion | |
| This script makes no changes. Paste the printed `fix:` commands selectively. | |
| Filters: | |
| bash audit.sh --quiet # only show fails | |
| bash audit.sh --only NPM-005 # run one rule | |
| bash audit.sh --skip GH-001 # skip rule(s) | |
| bash audit.sh --json | jq . # one finding per line | |
| bash audit.sh --list # all rule IDs + severities | |
| EOF | |
| fi | |
| # Exit code: 2 if any critical/high, 1 if any medium, 0 otherwise | |
| if [[ $CRIT_COUNT -gt 0 || $HIGH_COUNT -gt 0 ]]; then exit 2 | |
| elif [[ $MED_COUNT -gt 0 ]]; then exit 1 | |
| else exit 0 | |
| fi |
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
| 0dc0d2823d2065fb88ff0044571b44071bde5b24a27dc0476a2dc16bfa38d366 dev-supply-chain-audit.sh |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment