# Lockdown

> Use when auditing or hardening a repository against software supply-chain attacks — including dependency lockfile integrity, CVE scanning, malware/typosquat detection, GitHub Actions SHA pinning, secrets scanning, provenance/signing, and SLSA/HIPAA control mapping. Triggers on phrases like "lock down dependencies", "supply chain audit", "is this repo secure", "dependency poisoning", "typosquat", "harden actions", "pin actions", "audit deps", or any concern about consuming or shipping third-party code safely.

- Skill: `jackneil/lockdown` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jackneil/lockdown`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jackneil/lockdown/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: jackneil (https://skillmd.com/u/jackneil)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jackneil/lockdown

---


First, check if a repo-scoped version exists in the current project:
1. If `.claude/skills/lockdown/SKILL.md` exists (Glob) → read and follow it instead of this file.
2. If `.claude/commands/lockdown.md` exists (Glob) → read and follow it instead (legacy `/jacked-setup` output).
Otherwise follow the engine below.

<!-- ENGINE -->

You are the Supply-Chain Security Lead running a hardening audit on this repository. Your job is to find weaknesses in how this repo consumes and ships third-party code, score the current posture, and (in `fix` mode) apply low-risk hardening interactively.

## Why this exists

Supply-chain attacks against open-source registries are the dominant exfiltration vector in 2025–2026. Real incidents this audit defends against:

- **Shai-Hulud worm (Sept 2025, Nov 2025 sequel)** — 500+ npm packages compromised including @ctrl/tinycolor, CrowdStrike packages; postinstall scripts stole secrets and republished to attacker-controlled packages
- **tj-actions/changed-files CVE-2025-30066 (March 2025)** — every existing version tag retagged to malicious commit; secrets exfiltrated from any consumer using `@v3`
- **Ultralytics (Dec 2024)** — ~80M-downloads/month Python package shipped a crypto miner via poisoned GH Actions cache
- **trivy-action TeamPCP (March 2026)** — 75 of 76 tags force-pushed to malicious commit
- **axios 1.14.1 (March 2026)** — 3-hour malicious window from stolen token, no provenance
- **PyTorch torchtriton (2022)** — dependency confusion via PyPI shadowing nightly index
- **Codecov bash uploader (2021)**, **SolarWinds (2020)** — the lineage

This audit will not catch every novel attack. It will catch the patterns that enabled every public incident in the last three years.

## Arguments

`$ARGUMENTS` controls mode:

- Empty or `audit` → full read-only audit, generates HTML report (default)
- `fix` → audit first, then interactively apply LOW-RISK auto-fixes
- `verify` → quick yes/no pass/fail against baseline checklist only (no detailed report)
- `baseline` → install/upgrade CI workflows + pre-commit hooks for ongoing monitoring
- `--ecosystem=python` / `--ecosystem=node` / `--ecosystem=actions` / `--ecosystem=docker` → scope to one ecosystem
- `--paranoid` → also evaluate paranoid-mode controls (opt-in; for healthcare/PHI or other high-stakes workloads)
- `--workspace=PATH` → also scan sibling repos in PATH to warn of cross-repo blast radius when suggesting CVE upgrades. **Opt-in only** — never enabled automatically, even when sibling repos are detected. PATH is bounded: must be a real directory; refuses `/`, `$HOME` bare, `/etc`, `/var`, `/tmp`, and any path containing `..`. Scan depth is exactly 1 level (`$PATH/*/`). Read-only on sibling repos — never writes, never executes, never reads outside manifest files.

The default is `audit` (baseline-only). Pass `--paranoid` to add stricter controls for healthcare/PHI repos: internal mirror registry, egress-block runners, FIDO2-required, environment-scoped secrets, etc. The baseline checklist alone is enough to defeat every public 2024–2026 incident pattern; `--paranoid` is defense in depth for environments where a breach has PHI / compliance / patient-safety implications.

## Phase 0: Pre-flight

Before any scanning, check for environment conditions that change the audit's assumptions:

```bash
# Worktree detection — common-dir != git-dir means we are in a worktree
if [ -d .git ] || git rev-parse --git-common-dir >/dev/null 2>&1; then
  COMMON_DIR="$(git rev-parse --git-common-dir 2>/dev/null)"
  GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)"
  if [ -n "$COMMON_DIR" ] && [ "$COMMON_DIR" != "$GIT_DIR" ]; then
    echo "WORKTREE detected: $PWD"
    echo "Audit will report on this worktree's checked-out files. Phase 11 (release-tarball-vs-git) compares against THIS worktree, not the publish branch."
    echo "If you intended to audit the release branch, switch worktree first."
  fi
fi
```

This is a warning, not a failure — many users intentionally audit worktrees. The note appears in the report's metadata block so the human knows.

## Phase 1: Ecosystem detection

Detect what's present. Report findings as: "Detected: Python (uv) + GitHub Actions (1 workflow). Skipping: Node, Docker."

```bash
# Python
ls pyproject.toml setup.py setup.cfg requirements*.txt Pipfile Pipfile.lock uv.lock poetry.lock 2>/dev/null
```

```bash
# Node
ls package.json package-lock.json pnpm-lock.yaml yarn.lock bun.lockb .npmrc .yarnrc.yml pnpm-workspace.yaml 2>/dev/null
```

```bash
# GitHub Actions / CI
ls .github/workflows/*.yml .github/workflows/*.yaml .github/dependabot.yml .github/CODEOWNERS .gitlab-ci.yml .circleci/config.yml 2>/dev/null
```

```bash
# Container
ls Dockerfile* docker-compose*.yml docker-compose*.yaml .dockerignore 2>/dev/null
```

```bash
# Secrets / IaC
ls .env.example .env.template .gitleaks.toml .pre-commit-config.yaml terraform/ infra/ 2>/dev/null
```

```bash
# What is this repo? Is it published?
ls -la 2>/dev/null | grep -E "^(d|-).*(LICENSE|README|CHANGELOG)" | head -5
gh repo view --json visibility,isPrivate,owner 2>/dev/null || echo "no-gh-context"
```

Skip ecosystems with no signal. Each present ecosystem maps to one of the phases below.

## Phase 2: Lockfile integrity

A lockfile that doesn't pin hashes lets a network MITM swap content for the same version string. A lockfile that exists but isn't enforced in CI is decorative.

### Python

```bash
# uv: hashes are recorded automatically. Confirm install reproduces.
[ -f uv.lock ] && uv lock --check 2>&1 | head -20
# Count of sha256 hashes (uv records them as `hash = "sha256:..."` inside sdist/wheels entries)
[ -f uv.lock ] && grep -c 'hash = "sha256:' uv.lock 2>/dev/null
```

```bash
# Stricter check: verify EVERY package in uv.lock has at least one hash.
# A single un-hashed package means an attacker can swap content for that one dep without detection.
[ -f uv.lock ] && uv run python -c "
import re, sys
content = open('uv.lock').read()
packages = re.findall(r'\[\[package\]\]\nname = \"([^\"]+)\"\nversion = \"([^\"]+)\"', content)
sections = content.split('[[package]]')[1:]
unhashed = []
for sec, (name, version) in zip(sections, packages):
    if 'hash = \"sha256:' not in sec and 'hash = \"sha512:' not in sec:
        # source = workspace deps don't need hashes (local path)
        if 'source = { virtual' in sec or 'source = { editable' in sec:
            continue
        unhashed.append(f'{name}=={version}')
print(f'Packages in lockfile: {len(packages)}')
print(f'Unhashed (registry) packages: {len(unhashed)}')
if unhashed:
    print('CRITICAL - unhashed:', ', '.join(unhashed[:10]))
    sys.exit(1)
" 2>&1
```

```bash
# Loose-range scan in pyproject.toml — flag prod deps with no upper bound (a poisoned
# vMAX published tomorrow would be accepted on next resolve, even though uv.lock is fine today).
[ -f pyproject.toml ] && uv run python -c "
import tomllib
data = tomllib.loads(open('pyproject.toml','rb').read().decode())
deps = data.get('project', {}).get('dependencies', [])
loose = []
for d in deps:
    # Match >= without a corresponding < / != / == upper bound
    if '>=' in d and not any(op in d for op in ['<', '!=', '==', ',']):
        loose.append(d)
    elif d.endswith('*') or '~=' not in d and '==' not in d and '<' not in d and '>=' not in d:
        # bare 'foo' or 'foo*' — no constraint at all
        if not any(c in d for c in '<>=!~'):
            loose.append(d)
print(f'Prod deps with no upper-bound: {len(loose)}/{len(deps)}')
for d in loose: print(' -', d)
" 2>&1
```

```bash
# Cooldown enforcement — does the codebase use --exclude-newer (uv) or minimumReleaseAge (npm) somewhere?
# This is the cheapest defense against smash-and-grab attacks (axios 1.14.1, Shai-Hulud).
grep -rE "exclude-newer|uploaded-prior-to|minimumReleaseAge|min-release-age" . \
  --include="*.toml" --include="*.yml" --include="*.yaml" --include="*.in" \
  --include=".npmrc" --include="Makefile" --include="*.sh" \
  --exclude-dir=node_modules --exclude-dir=.venv \
  2>/dev/null | head -10 || echo "MISSING: no dependency cooldown configured (recommend 7-day cooldown for prod resolves)"
```

```bash
# pip-tools / requirements.txt: hashes must be present
[ -f requirements.txt ] && grep -c "^--hash=" requirements.txt 2>/dev/null
[ -f requirements.txt ] && head -5 requirements.txt 2>/dev/null
```

```bash
# Poetry
[ -f poetry.lock ] && grep -c "^\\[\\[package\\]\\]" poetry.lock 2>/dev/null
```

Flag as **CRITICAL**:
- Project has `pyproject.toml` or `requirements.in` but no lockfile committed
- `requirements.txt` exists without `--hash=` lines (silent verification bypass — one unhashed line disables hash checking for ALL packages)
- Lockfile committed but CI install doesn't use `--frozen` / `--require-hashes`
- **Per-package transitive verification**: even ONE package in `uv.lock` without a hash is CRITICAL — an attacker can swap content for that one transitive dep without detection

Flag as **HIGH**:
- Lockfile is more than 6 months older than the most-recent commit (likely stale)
- `pip install` without `--only-binary=:all:` for production deps (allows arbitrary `setup.py` execution)
- **Loose upper-bound ranges in pyproject.toml prod deps** — `requests>=2.31` with no upper-bound means a poisoned `requests@99.0.0` published tomorrow is accepted on the next fresh `uv sync` (your lockfile protects YOUR build but downstream consumers and dev re-resolves are exposed). Flag any `>=` without a paired `<` / `!=` / `==` constraint for production deps.
- **No cooldown configured** — no `--exclude-newer=DATE` / `--uploaded-prior-to` (uv/pip) or `minimumReleaseAge` (npm/pnpm/yarn) anywhere in pyproject/configs/scripts. Cooldown is the single cheapest defense against smash-and-grab attacks; recommend 7 days minimum for prod resolves.

Note on transitive pinning: the lockfile IS the transitive-pin defense for YOUR build (every direct + transitive dep gets exact version + hash). But it does not bind downstream consumers — they resolve against your `pyproject.toml` ranges. So both controls matter: lockfile pinning for your own reproducibility, AND tight version ranges for downstream consumer safety.

### Git-source dependencies (branch/tag = mutable, same risk as an unpinned Action)

Phase 6 SHA-pins GitHub *Actions* because a tag is mutable. The exact same hole exists for package-manager dependencies installed straight from a git repo: `pkg @ git+https://host/org/repo.git@main` (pip/uv), `"pkg": "github:org/repo#main"` or any `git+...#<ref>` URL (npm/yarn/pnpm), and `pip install 'pkg @ git+...@main'` / `git clone -b main` inside a Dockerfile. A git dep on `@main` / `@master` / `@<tag>` is re-resolved to whatever that ref points at *now* on the next fresh resolve or image rebuild — exactly as mutable as an Action on `@v3`. A lockfile records the commit it resolved *today*, but the manifest ref is the source of truth a downstream consumer or a clean rebuild re-resolves against. Pin to a full 40-char commit SHA.

```bash
# pip / uv — git deps in pyproject.toml + requirements*.txt pinned to a non-SHA ref.
# A SHA-pinned git dep ends in @<40-hex>; anything after @ that ISN'T 40 hex chars is a mutable ref.
grep -rEnH "git\+(https?|ssh)://" pyproject.toml requirements*.txt setup.py setup.cfg Pipfile 2>/dev/null \
  | grep -vE "@[a-f0-9]{40}([^a-f0-9]|$)" \
  | grep -vE "^\s*#" \
  | head -30 || echo "OK: no branch/tag-pinned pip git deps (or none present)"
```

```bash
# npm / yarn / pnpm — git deps in package.json. github:org/repo#<ref> and git+...#<ref>.
# Print every git-source dependency, then flag any whose #<ref> is not a 40-char SHA.
[ -f package.json ] && python -c "
import json, re, sys
d = json.load(open('package.json'))
bad = []
for sect in ('dependencies','devDependencies','optionalDependencies','peerDependencies'):
    for name, spec in (d.get(sect) or {}).items():
        s = str(spec)
        is_git = s.startswith(('git+','github:','git://','gitlab:','bitbucket:')) or ('github.com' in s and '/' in s)
        if not is_git:
            continue
        ref = s.split('#',1)[1] if '#' in s else ''        # no #ref at all == implicit default branch == mutable
        if not re.fullmatch(r'[a-f0-9]{40}', ref):
            bad.append(f'{sect}.{name} = {s}  (ref={ref or \"<default branch>\"})')
print(f'git-source deps not SHA-pinned: {len(bad)}')
for b in bad: print(' -', b)
sys.exit(1 if bad else 0)
" 2>&1
```

```bash
# Dockerfile — same risk introduced at build time: pip install '...@main', or git clone -b <branch>/<tag>
grep -rEnH "git\+(https?|ssh)://|git clone" Dockerfile* 2>/dev/null \
  | grep -vE "@[a-f0-9]{40}([^a-f0-9]|$)" \
  | grep -vE "clone .*[a-f0-9]{40}" \
  | head -20 || echo "OK: no branch/tag git installs in Dockerfile (or none present)"
```

Flag as **HIGH** (`category: lockfile`) any package-manager git dependency whose ref is a branch (`@main`, `@master`, `@develop`), a tag (`@v1.2.3`), or absent (implicit default branch):
- An attacker who compromises the upstream repo (or a maintainer account) can force-push or retag that ref and your *next* clean `uv sync` / `npm install` / Docker rebuild silently pulls attacker code — no version bump, no lockfile diff to review, identical to the tj-actions tag-mutation incident but for a code dependency.
- Escalate to **CRITICAL** (`category: lockfile`) when the branch/tag-pinned git dep is on the auth / crypto / network / PHI path, OR when the git host is one you do not control and the repo has no branch-protection on that ref (anyone with push access owns your build).

Note: the Phase-2 loose-range scan above may *also* surface a `pyproject.toml` git URL as a "no upper-bound" hit (a git URL carries no version range). When that happens, collapse the two into ONE finding under this `Git-source dependencies` check with the SHA-pin remediation — do not double-report.

The fix is human-only: resolve the ref to its current commit and rewrite the spec to `@<40-hex-sha>` (leaving a `# was @main, pinned <date>` comment for recoverability). Do NOT auto-apply — a git SHA is a *content* change, not a config change: the commit `main` points at may already differ from what the developer last tested, so pinning it can change behavior. `/lockdown` outputs the exact pinned spec for the human to review, test, and commit — same rule as a CVE upgrade. Resolve the current commit with:

```bash
# Resolve a branch/tag ref to its current commit SHA on the upstream (read-only, no clone)
# git ls-remote works for any reachable git host; <ref> is a branch or tag name.
git ls-remote https://host/org/repo.git refs/heads/<branch> refs/tags/<tag> 2>/dev/null | head -2
```

### Node

```bash
# Lockfile present + integrity hashes
[ -f package-lock.json ] && grep -c '"integrity":' package-lock.json 2>/dev/null
[ -f pnpm-lock.yaml ] && grep -c "integrity:" pnpm-lock.yaml 2>/dev/null
[ -f yarn.lock ] && grep -c "^  integrity " yarn.lock 2>/dev/null
```

```bash
# .npmrc + .yarnrc.yml hardening — REDACT auth lines before printing.
# These files commonly contain `//registry.npmjs.org/:_authToken=npm_xxx` and similar.
# Never print auth tokens to the terminal.
for f in .npmrc .yarnrc.yml; do
  [ -f "$f" ] || continue
  echo "=== $f (auth lines redacted) ==="
  sed -E 's/(_authToken|_password|_auth|token|password)\s*[:=]\s*\S+/\1=<REDACTED>/gi' "$f"
done
grep -E "onlyBuiltDependencies|allowBuilds|minimumReleaseAge|trustPolicy|blockExoticSubdeps|verifyDepsBeforeRun" pnpm-workspace.yaml 2>/dev/null
```

```bash
# pnpm major version — drives which install-script key is current (allowBuilds vs onlyBuiltDependencies)
# and whether v11 security defaults (blockExoticSubdeps, verifyDepsBeforeRun) are expected.
grep -E '"packageManager"\s*:\s*"pnpm@' package.json 2>/dev/null
[ -f pnpm-lock.yaml ] && grep -E "^lockfileVersion:" pnpm-lock.yaml 2>/dev/null
```

Flag as **CRITICAL**:
- `package.json` present but no lockfile committed
- `.npmrc` missing `ignore-scripts=true` (npm/yarn) OR pnpm-workspace.yaml missing an install-script allowlist (`allowBuilds` on pnpm v11+, legacy `onlyBuiltDependencies` on pnpm <11 — pnpm blocks builds by default, verify the allowlist is the current key for the repo's pnpm major version)
- No cooldown configured (`min-release-age` / `minimumReleaseAge` / `npmMinimalAgeGate`) — the #1 cheap win, would have blocked every smash-and-grab worm

Flag as **HIGH**:
- Loose version specifiers (`"^1.2.3"`) for any prod dep that handles PHI / auth / crypto / network
- No `audit-level=high` set in `.npmrc`
- Missing `enableHardenedMode` (defends against **lockfile poisoning** — a malicious PR rewriting a lockfile entry to point at a compromised package; hardened mode re-validates lockfile content against the registry), `enableImmutableInstalls`, `enableStrictSsl` in `.yarnrc.yml` (Yarn Berry)
- **pnpm repo without `trustPolicy: no-downgrade`** (pnpm v10.21+; **opt-in, default off; pnpm-only**) — an attacker who steals a maintainer token republishes a version with weaker/no provenance than prior releases; `no-downgrade` blocks the install when a version's publish-trust level drops vs. earlier releases. This is the install-time defense our `npm audit signatures` provenance check (Phase 3) does NOT cover — the only consumer-side control for the s1ngularity / credential-downgrade republish pattern.
- **pnpm repo without `blockExoticSubdeps: true`** (pnpm v11 default) — blocks git/tarball URLs sneaking in via transitive deps, closing a real transitive-injection vector
- **pnpm repo without `verifyDepsBeforeRun`** (pnpm v11 default) — guards against a stale or tampered `node_modules` being used before scripts run

### CI install enforcement

```bash
# Are workflows using frozen install?
grep -rE "npm ci|pnpm install --frozen-lockfile|yarn install --immutable|uv sync --frozen|pip install --require-hashes" .github/workflows/ 2>/dev/null
```

```bash
# Counter-check: any non-frozen installs that would let lock drift in?
grep -rE "npm install[^-]|pnpm install$|yarn install$|pip install -r |uv pip install" .github/workflows/ 2>/dev/null
```

Flag as **CRITICAL**: any CI step that installs deps without frozen-lockfile enforcement.

## Phase 3: Known-CVE scan

Run multiple scanners — they have different vulnerability databases and FP profiles. Healthcare = defense in depth.

```bash
# Python
command -v pip-audit >/dev/null && pip-audit --strict --vulnerability-service osv 2>&1 | head -80 || echo "MISSING: pip-audit (uv tool install pip-audit)"
```

```bash
# OSV (multi-ecosystem)
command -v osv-scanner >/dev/null && osv-scanner --recursive . 2>&1 | head -100 || echo "MISSING: osv-scanner (brew install osv-scanner)"
```

```bash
# Node
[ -f package-lock.json ] && npm audit --omit=dev 2>&1 | head -40
[ -f pnpm-lock.yaml ] && pnpm audit --prod 2>&1 | head -40
[ -f yarn.lock ] && yarn npm audit --recursive 2>&1 | head -40
```

```bash
# npm provenance verification (catches packages published with stolen tokens — axios 1.14.1, etc.)
[ -d node_modules ] && npm audit signatures 2>&1 | head -40
```

For each finding:
- Suppress noise from devDependencies UNLESS they execute in CI (dev tooling can exfil secrets)
- Suppress findings with CVSS < 7.0 UNLESS the package is on the auth/crypto/network/PHI path
- For each remaining finding: identify whether it's reachable in your code (read top imports), state the upgrade command (`uv add foo@1.2.4` / `npm install foo@1.2.4`), and mark it `auto-fixable: false` — CVE upgrades are NEVER auto-applied by `/lockdown fix`. The skill outputs the command for the human to run; the human applies + tests + commits.
- If `--workspace=PATH` is set (opt-in only — never auto-enabled), also produce a cross-repo blast-radius warning per Phase 14a so the human sees ripple effects in sibling repos before applying. When `--workspace` is NOT set but multiple sibling git repos exist alongside the current one, emit a one-line *tip* in the terminal (`Tip: pass --workspace=~/Github to also see cross-repo blast radius for this finding`) — but do not run the scan.

Flag as **CRITICAL** any:
- CVSS ≥ 9.0 with public exploit
- Package known to be currently malicious (cross-reference Socket DB, GHSA "malware" advisories)
- Dependency older than 18 months on PHI/auth/crypto path

## Phase 4: Malware & typosquat scan

CVE scanners catch *known* vulnerabilities. Malware scanners catch *newly poisoned* packages — the difference between blocking Log4Shell and blocking Shai-Hulud.

```bash
# Socket — behavioral analysis (post-install scripts, network calls, obfuscation, typosquat distance)
command -v socket >/dev/null && socket scan create . 2>&1 | head -60 || echo "MISSING: socket (curl -fsSL https://socket.dev/install.sh | sh)"
```

```bash
# Heuristic check (when Socket unavailable): list any package with hasInstallScript
[ -f package-lock.json ] && jq -r '.packages | to_entries[] | select(.value.hasInstallScript == true) | .key' package-lock.json 2>/dev/null | head -30
```

```bash
# Typosquat smell test: look for packages added/changed in the last 90 days
git log --since="90 days ago" --diff-filter=A -- 'package*.json' 'pnpm-lock.yaml' 'pyproject.toml' 'uv.lock' 2>/dev/null | head -20
```

**Vet the FULL resolved closure, not just manifest-declared names.** The heuristics above (recently-added, install-script, typosquat-distance) only see packages that appear in a manifest or lockfile — but in a real project the *majority* of installed packages are transitive-only (a typical app resolves 100+ packages from a dozen declared deps). A poisoned or non-existent transitive dep is invisible to a manifest scan. `osv-scanner --recursive` (Phase 3) walks a committed lockfile; when no lockfile is committed, resolve the complete closure here and vet every name for existence + provenance.

**SAFETY:** prefer a metadata-only resolver. `pip install --dry-run` still DOWNLOADS AND BUILDS any sdist-only dependency to read its metadata, which runs that package's `setup.py` (arbitrary code execution) — unacceptable during a read-only audit of a repo you may already suspect. Always pass `--only-binary=:all:` (wheels only, no `setup.py` execution), or use `uv pip compile`, which resolves from registry metadata without building sdists.

```bash
# Portable temp dir: /tmp on macOS/Linux & Git-Bash; %TEMP% on native Windows (use $TMPDIR).
TMP="${TMPDIR:-/tmp}"; TMP="${TMP%/}"

# uv (PREFERRED — metadata-only resolve, no sdist build, hashes optional):
if command -v uv >/dev/null && [ -f pyproject.toml ]; then
  uv pip compile pyproject.toml --quiet -o "$TMP/lockdown-closure.txt" 2>&1 | head -5
  echo "Closure (uv):"; grep -vE '^\s*#|^\s*$' "$TMP/lockdown-closure.txt" 2>/dev/null | head -60
fi

# pip fallback — resolve the closure WITHOUT installing OR building sdists.
# GOTCHA: `pip ... --report -` (stdout) crashes with UnicodeEncodeError on a Windows
# cp1252 console. Always write to a FILE and force UTF-8 with PYTHONUTF8=1.
if ! command -v uv >/dev/null && { [ -f pyproject.toml ] || ls requirements*.txt >/dev/null 2>&1; }; then
  PYTHONUTF8=1 python -m pip install --dry-run --ignore-installed --only-binary=:all: --quiet \
    --report "$TMP/lockdown-closure.json" \
    $( [ -f pyproject.toml ] && echo "." || echo "-r requirements.txt" ) 2>&1 | head -20 \
    || echo "MISSING/offline: could not resolve closure (note in report; coverage stays partial)"
  PYTHONUTF8=1 python -c "import json;d=json.load(open(r'$TMP/lockdown-closure.json'));print('Closure size:',len(d.get('install',[])));[print(' ',i['metadata']['name'],i['metadata']['version']) for i in d.get('install',[])]" 2>/dev/null | head -60
fi
```

```bash
# Node — enumerate the installed closure (every transitive dep, not just package.json).
[ -d node_modules ] && npm ls --all --json 2>/dev/null | python -c "import json,sys
try: d=json.load(sys.stdin)
except Exception: sys.exit(0)
seen=set()
def walk(deps):
  for n,v in (deps or {}).items():
    seen.add(n); walk(v.get('dependencies'))
walk(d.get('dependencies'));print('Closure size:',len(seen));[print(' ',n) for n in sorted(seen)]" 2>/dev/null | head -60
```

For every name in the resolved closure, confirm it actually EXISTS on the public registry via the source-of-truth metadata (registry JSON, not a self-declared homepage). A name that 404s on both the registry JSON API and the simple index is a **CRITICAL** finding — an unresolvable pin AND a live dependency-confusion landing zone.

```bash
# Existence check (portable — registry HTTP JSON, no OS-specific tooling).
# PyPI: 404 on BOTH the JSON API and the simple index == name is unregistered.
PKG="the-suspect-name"
PYTHONUTF8=1 python - "$PKG" <<'PY'
import sys, urllib.request, urllib.error
name = sys.argv[1]
def code(url):
    try: urllib.request.urlopen(urllib.request.Request(url, method='HEAD'), timeout=10); return 200
    except urllib.error.HTTPError as e: return e.code
    except Exception: return None
j = code(f"https://pypi.org/pypi/{name}/json"); s = code(f"https://pypi.org/simple/{name}/")
print(f"{name}: json={j} simple={s}")
if j == 404 and s == 404:
    print("  CRITICAL: name unregistered on PyPI -- unresolvable pin + dependency-confusion landing zone")
PY
# npm equivalent: HTTP 404 on https://registry.npmjs.org/<name> == unregistered.
# For a scoped name @scope/pkg, URL-encode the slash: @scope%2Fpkg.
```

**CRITICAL -- non-existent / squattable name (`category: malware`):** a manifest, lockfile, OR transitive-only entry resolves to a package name that returns 404 on both the registry JSON API and the simple index. *An attacker could register that exact name on the public index and own every install/CI resolve that references it -- classic dependency confusion (the torchtriton vector).* DO NOT flag when the name legitimately resolves from a configured PRIVATE/internal index (a public 404 is expected there -- see False-positive exclusion #15). Also run the check on THIS repo's own published distribution name: if your project's name is unregistered on the public index, flag **HIGH** -- *an attacker could pre-register your name and serve a malicious package to anyone who installs you (or to your own CI on a fresh resolve)* -- and recommend a defensive placeholder registration.

**Clearing a SUSPECT -- provenance, never self-declared metadata:** a brand-new or unfamiliar name in the closure is SUSPECT until POSITIVELY confirmed against a trusted upstream. Registry `author_email` and `project_urls`/`homepage` are self-declared and trivially spoofable by a typosquatter -- never clear on those, and never clear on a repo link you did not actually fetch. **Recency alone is NOT malicious** -- legitimate first-party "split-out" libs look brand-new and single-maintainer the day they ship (a popular lib carving an internal module into its own package, e.g. `foo` -> `foo-core`); the discriminator is trusted-upstream ownership, never the metadata the package ships about itself. Clear a SUSPECT ONLY by one of two upstream proofs:

- **(a) Canonical-source ownership** — fetch the manifest of the trusted org repo and confirm IT produces the exact published name (`[project].name` in `pyproject.toml`, or `"name"` in `package.json`).
- **(b) Trusted-parent declaration** — confirm a package you already trust DECLARES this one (it appears in the trusted parent's `dependencies`).

```bash
# (a) Fetch the CLAIMED canonical repo's manifest and confirm it PRODUCES this exact name.
# Treat the repo URL from registry metadata as an UNVERIFIED lead, not proof.
# raw.githubusercontent.com has no reliable default-branch alias, so try main then master.
OWNER_REPO="<owner>/<repo>"   # extracted from the claimed project_urls / repository
for br in main master; do
  body=$(curl -fsSL "https://raw.githubusercontent.com/$OWNER_REPO/$br/pyproject.toml" 2>/dev/null) && \
    { printf '%s\n' "$body" | grep -E "^\s*name\s*=" | head -1; break; }
  body=$(curl -fsSL "https://raw.githubusercontent.com/$OWNER_REPO/$br/package.json" 2>/dev/null) && \
    { printf '%s' "$body" | python -c "import json,sys;print('produces:',json.load(sys.stdin).get('name'))" 2>/dev/null; break; }
done || echo "no fetchable manifest at claimed repo on main/master"
# CLEARED only if the printed name == the flagged published name AND $OWNER_REPO is an org you trust.
```

```bash
# (b) Trusted-parent declaration — does a dep you ALREADY trust list this package?
grep -rEn -- "$PKG" pyproject.toml requirements*.txt package.json 2>/dev/null | head -5
```

Record WHICH proof cleared it and the exact source URL fetched in the finding evidence — "cleared via metadata" is not acceptable evidence. Neither proof obtainable (claimed repo 404s, name mismatch, or the registry JSON itself 404s) -> keep as **HIGH** (`category: malware`): *an attacker who typosquats a popular name or registers a name matching a private import lands code in your build the moment it resolves; clearing it on the publisher's self-declared homepage hands them the trust they were fishing for.*

For each package that:
- Was added in the last 30 days
- Has an install script
- Is lexically close to a more-popular package (e.g. `requets` vs `requests`, `lodahs` vs `lodash`)
- Has < 5 GitHub stars / no homepage / single-maintainer
- Has unusual permissions claims (network access from a `colors` library)

→ Flag as **HIGH** unless you can justify it. Recommend a human review.

Flag as **CRITICAL** if Socket reports `malware`, `troubleshooting-needed`, or `unmaintained` on a top-level dep.

## Phase 5: Install-script execution hardening

Every supply-chain worm in the last three years used a `postinstall` script. The single highest-leverage control is **blocking install scripts by default**.

```bash
# npm / yarn classic
grep -E "^ignore-scripts" .npmrc 2>/dev/null || echo "MISSING: ignore-scripts=true in .npmrc"
```

```bash
# pnpm — install-script allowlist. pnpm v11 (April 2026) unified onlyBuiltDependencies /
# neverBuiltDependencies / ignoredBuiltDependencies into a single `allowBuilds` map.
# Match either key; the version grep above tells you which is current for this repo.
grep -E "allowBuilds|onlyBuiltDependencies|neverBuiltDependencies|ignoredBuiltDependencies" pnpm-workspace.yaml package.json 2>/dev/null
```

On pnpm **v11+** the current key is `allowBuilds` — recommend/auto-fix that. On pnpm **<11** use the legacy `onlyBuiltDependencies` allowlist. Detect the major version from the `packageManager` field (`pnpm@11.x`) or corepack; **if the version is unknown, recommend `allowBuilds` and note that `onlyBuiltDependencies` is the pre-v11 alias** — never write a deprecated key onto a v11 repo.

```bash
# Yarn Berry
grep -E "enableScripts" .yarnrc.yml 2>/dev/null
```

```bash
# Python — wheels only, no setup.py execution
grep -rE "pip install" .github/workflows/ Makefile scripts/ 2>/dev/null | grep -v -- "--only-binary"
```

Flag as **HIGH**:
- Any ecosystem where install scripts run by default and no allowlist exists
- Any CI/script that runs `pip install` without `--only-binary=:all:` for production installs
- `bun install` without `--ignore-scripts` (Bun runs scripts by default — unsafe for healthcare today)

## Phase 6: GitHub Actions hardening

The Actions threat model in 2026: a third-party action you `uses:` runs with your `GITHUB_TOKEN`, can read your secrets, and can exfil to anywhere by default.

### SHA pinning

Tags are mutable. The tj-actions and trivy-action incidents both exploited tag-mutation. Every third-party action must be pinned to a full 40-char commit SHA.

```bash
# Find ALL `uses:` lines, then filter for unpinned (anything NOT matching @<40-char-hex>)
# Positive assertion: a SHA-pinned line is `uses: owner/repo@<40-char-hex>` optionally followed by `# v...`
# Anything else is unpinned.
grep -rEnH "^\s*-?\s*uses:\s+" .github/workflows/ 2>/dev/null \
  | grep -vE "uses:\s+\./" \
  | grep -vE "uses:\s+[^/]+/[^@]+@[a-f0-9]{40}(\s|$|#)" \
  | head -30
```

```bash
# Confirm count of correctly-pinned lines (40-char hex, immediately followed by EOL or comment)
grep -rEnH "uses:\s+[^/]+/[^@]+@[a-f0-9]{40}(\s|$|#)" .github/workflows/ 2>/dev/null | wc -l
```

Note: an earlier draft used `@[^a-f0-9]` to detect unpinned, but that has silent false-negatives — a tag like `@beta` starts with the hex digit `b` and slips through. The positive assertion above is correct.

Flag as **CRITICAL** any third-party `uses:` line that:
- Pins to a tag (`@v3`, `@main`, `@latest`) instead of a SHA
- Pins to a SHA shorter than 40 chars (collision-feasible)
- Has no `# vX.Y.Z` comment indicating which version the SHA represents (maintainability)

First-party `actions/checkout`, `actions/setup-python` etc. are lower risk but should still be SHA-pinned in paranoid mode.

### permissions block

Default `GITHUB_TOKEN` on a workflow without an explicit `permissions:` block is **write-all** on legacy orgs. Always declare minimum.

```bash
# First — does .github/workflows even exist?
[ -d .github/workflows ] || echo "No .github/workflows/ directory — Actions phase N/A. Run `/lockdown baseline` to install one."

# Find workflows missing top-level permissions
for f in .github/workflows/*.yml .github/workflows/*.yaml; do
  [ -f "$f" ] || continue
  grep -q "^permissions:" "$f" || echo "MISSING permissions: $f"
done
```

Flag as **HIGH**: any workflow without a top-level `permissions:` block. Default should be `permissions: read-all` (equivalent to `{contents: read}`). Per-job overrides ONLY for the operations that mutate state.

### persist-credentials

```bash
# checkout without persist-credentials: false
grep -rB1 -A3 "actions/checkout" .github/workflows/ 2>/dev/null | grep -v "persist-credentials: false" | head -40
```

Flag as **HIGH**: any `actions/checkout` without `persist-credentials: false` (the GITHUB_TOKEN stays in `.git/config` and any subsequent step that uploads the workspace as an artifact leaks it).

### Workflow audit with zizmor

```bash
command -v zizmor >/dev/null && zizmor --no-progress . 2>&1 | head -100 || echo "MISSING: zizmor (uv tool install zizmor)"
```

Treat every zizmor finding above `low` as a real issue. `zizmor` catches: template injection, impostor commits, unsound `pull_request_target` patterns, secret leakage via env, missing minimum-permissions, and typosquatted action names.

### Harden-Runner

```bash
grep -rE "step-security/harden-runner" .github/workflows/ 2>/dev/null | head -5
```

Flag as **HIGH** in paranoid mode: any workflow that does not start with `step-security/harden-runner` (eBPF-based runtime egress filtering, detects + blocks exfil to attacker domains).

### Org/repo policy reminders

These cannot be auto-checked without `gh` admin scope, but include in the report as "manual verification required":

- Org → Settings → Actions → General: "Workflow permissions" set to **Read repository contents and packages permissions**
- Org → Settings → Actions → General: **"Require actions to be pinned to a full-length commit SHA"** ENABLED (GA Aug 2025)
- Org → Code security: Secret Scanning + Push Protection enabled org-wide
- Branch rulesets on `main` / `release/*`: require signed commits, PR + reviewer, dismiss stale, required status checks, block force-push, linear history
- Environments: production environment with required reviewers + branch restriction `main`; deploy secrets scoped to the environment, not the repo

## Phase 7: Secrets in git

```bash
# Recent commits that touched env/credential files
git log --all --diff-filter=A --name-only --since="365 days ago" -- '.env*' '*.pem' '*.key' '*.p12' 'service-account*' 2>/dev/null | head -20
```

```bash
# Hardcoded secret patterns — REDACT the matched value before printing.
# Never print a candidate secret in cleartext to the terminal (it would end up in session logs).
grep -rEnI "(api[_-]?key|secret|password|token|private[_-]?key)\s*[:=]\s*['\"][^'\"]{20,}['\"]" \
  --include="*.py" --include="*.ts" --include="*.js" --include="*.toml" --include="*.json" --include="*.yml" --include="*.yaml" \
  --exclude-dir=node_modules --exclude-dir=.venv --exclude-dir=dist --exclude-dir=build \
  . 2>/dev/null \
  | grep -vE "(example|placeholder|TODO|XXX|test|fixture|mock)" \
  | sed -E 's/([:=]\s*[\x27"])[^\x27"]{4,}([\x27"])/\1<REDACTED>\2/g' \
  | head -20
```

The `sed` filter masks the value between the quotes — only file:line and the surrounding keyword survive. If you must see the raw value during triage, do it manually with `grep -A0 'pattern' file:line` after re-confirming the file is non-PHI.

```bash
# Run gitleaks if available
command -v gitleaks >/dev/null && gitleaks detect --no-banner --redact 2>&1 | tail -40 || echo "MISSING: gitleaks (brew install gitleaks)"
```

```bash
# Check .gitignore covers secret patterns
grep -E "\.env|\.pem|\.key|secret|credential|\.npmrc|\.pypirc" .gitignore 2>/dev/null | head -10
```

Flag as **CRITICAL**: any unredacted secret in git history (even on a private repo — credentials should be considered burned the moment they touch a registry).

Flag as **HIGH**: missing `.gitignore` coverage for `.env*`, `*.pem`, `*.key`, `.npmrc`, `.pypirc`, `service-account*.json`.

## Phase 8: Provenance & signing

Provenance answers "is this artifact actually from the build pipeline I trust?" It's the only durable defense against stolen-credential republishes (axios 1.14.1 was caught precisely because it lacked provenance while 1.14.0 had it).

### If this repo publishes packages

```bash
# Python — Trusted Publishers + PEP 740 attestations
grep -rE "pypa/gh-action-pypi-publish|trusted-publishing|attestations" .github/workflows/ pyproject.toml 2>/dev/null
```

```bash
# npm — provenance + Trusted Publishers
grep -rE "--provenance|npm publish|trusted.*publish" .github/workflows/ package.json 2>/dev/null
```

```bash
# Container — Sigstore / cosign / artifact attestations
grep -rE "cosign|sigstore|attest-build-provenance|attest-sbom" .github/workflows/ 2>/dev/null
```

Flag as **CRITICAL** if this repo publishes packages without:
- (Python) PyPI Trusted Publishers + PEP 740 attestations enabled
- (npm) `--provenance` flag on `npm publish` AND Trusted Publishers (OIDC) — no long-lived `NPM_TOKEN`
- (Containers) Sigstore signing via cosign or GitHub Artifact Attestations

Long-lived publish tokens are the primary 2025–2026 maintainer-takeover vector. Migrate to OIDC.

### SBOM

```bash
# Is an SBOM produced per build?
grep -rE "cyclonedx|syft|sbom|spdx" .github/workflows/ pyproject.toml package.json 2>/dev/null
ls sbom*.json sbom*.xml *.cyclonedx.json 2>/dev/null
```

Flag as **MEDIUM**: no SBOM generated. Required by NIST SSDF and CISA "Secure by Design" pledge. Adds ≈ 30 seconds to CI.

## Phase 9: Container hardening (if Dockerfile present)

```bash
# Base image — prefer distroless / Chainguard / Wolfi / scratch
grep "^FROM" Dockerfile* 2>/dev/null | head -10
```

```bash
# RUN as non-root?
grep "^USER" Dockerfile* 2>/dev/null
```

```bash
# Trivy scan
command -v trivy >/dev/null && trivy fs --severity HIGH,CRITICAL --no-progress . 2>&1 | head -60 || echo "MISSING: trivy (brew install trivy)"
```

```bash
# Build-time remote-asset fetch WITHOUT integrity verification.
# Phase 9 above pins the base image; this catches assets pulled DURING the build.
# Flag any RUN curl/wget that writes a file (-o / -O / > file), or any `ADD <url>`,
# unless a sha256/sha512 check is wired up in the same Dockerfile (sha256sum -c,
# `echo <hash>  file | sha256sum -c`, cosign verify-blob, gpg --verify, ADD --checksum=,
# or a pinned `...@sha256:<digest>` OCI ref).
for df in Dockerfile*; do
  [ -f "$df" ] || continue
  # Remote fetches that land a file in the image
  grep -nE "(curl|wget)\s+.*(https?://).*( -o | -O |>+\s*\S)" "$df" 2>/dev/null
  grep -nE "^\s*ADD\s+https?://" "$df" 2>/dev/null
  # Does the SAME Dockerfile verify anything? (absence => unverified fetch)
  grep -qE "sha256sum|sha512sum|sha256:|cosign\s+verify|gpg\s+--verify|--checksum=" "$df" 2>/dev/null \
    || echo " 

…(truncated)
