First, check if a repo-scoped version exists in the current project:
- If
.claude/skills/lockdown/SKILL.mdexists (Glob) → read and follow it instead of this file. - If
.claude/commands/lockdown.mdexists (Glob) → read and follow it instead (legacy/jacked-setupoutput). Otherwise follow the engine below.
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-fixesverify→ 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/,$HOMEbare,/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:
# 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."
# Python
ls pyproject.toml setup.py setup.cfg requirements*.txt Pipfile Pipfile.lock uv.lock poetry.lock 2>/dev/null
# Node
ls package.json package-lock.json pnpm-lock.yaml yarn.lock bun.lockb .npmrc .yarnrc.yml pnpm-workspace.yaml 2>/dev/null
# GitHub Actions / CI
ls .github/workflows/*.yml .github/workflows/*.yaml .github/dependabot.yml .github/CODEOWNERS .gitlab-ci.yml .circleci/config.yml 2>/dev/null
# Container
ls Dockerfile* docker-compose*.yml docker-compose*.yaml .dockerignore 2>/dev/null
# Secrets / IaC
ls .env.example .env.template .gitleaks.toml .pre-commit-config.yaml terraform/ infra/ 2>/dev/null
# 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
# 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
# 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
# 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
# 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)"
# 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
# Poetry
[ -f poetry.lock ] && grep -c "^\\[\\[package\\]\\]" poetry.lock 2>/dev/null
Flag as CRITICAL:
- Project has
pyproject.tomlorrequirements.inbut no lockfile committed requirements.txtexists 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.lockwithout 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 installwithout--only-binary=:all:for production deps (allows arbitrarysetup.pyexecution)- Loose upper-bound ranges in pyproject.toml prod deps —
requests>=2.31with no upper-bound means a poisonedrequests@99.0.0published tomorrow is accepted on the next freshuv 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) orminimumReleaseAge(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.
# 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)"
# 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
# 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:
# 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
# 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
# .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
# 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.jsonpresent but no lockfile committed.npmrcmissingignore-scripts=true(npm/yarn) OR pnpm-workspace.yaml missing an install-script allowlist (allowBuildson pnpm v11+, legacyonlyBuiltDependencieson 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=highset 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,enableStrictSslin.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-downgradeblocks the install when a version's publish-trust level drops vs. earlier releases. This is the install-time defense ournpm audit signaturesprovenance 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 tamperednode_modulesbeing used before scripts run
CI install enforcement
# 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
# 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.
# 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)"
# OSV (multi-ecosystem)
command -v osv-scanner >/dev/null && osv-scanner --recursive . 2>&1 | head -100 || echo "MISSING: osv-scanner (brew install osv-scanner)"
# 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
# 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 itauto-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=PATHis 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--workspaceis 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.
# 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)"
# 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
# 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.
# 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
# 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.
# 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].nameinpyproject.toml, or"name"inpackage.json). - (b) Trusted-parent declaration — confirm a package you already trust DECLARES this one (it appears in the trusted parent's
dependencies).
# (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.
# (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.
requetsvsrequests,lodahsvslodash) - Has < 5 GitHub stars / no homepage / single-maintainer
- Has unusual permissions claims (network access from a
colorslibrary)
→ 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.
# npm / yarn classic
grep -E "^ignore-scripts" .npmrc 2>/dev/null || echo "MISSING: ignore-scripts=true in .npmrc"
# 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.
# Yarn Berry
grep -E "enableScripts" .yarnrc.yml 2>/dev/null
# 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 installwithout--only-binary=:all:for production installs bun installwithout--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.
# 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
# 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.Zcomment 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.
# 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
# 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
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
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
# 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
# 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.
# Run gitleaks if available
command -v gitleaks >/dev/null && gitleaks detect --no-banner --redact 2>&1 | tail -40 || echo "MISSING: gitleaks (brew install gitleaks)"
# 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
# Python — Trusted Publishers + PEP 740 attestations
grep -rE "pypa/gh-action-pypi-publish|trusted-publishing|attestations" .github/workflows/ pyproject.toml 2>/dev/null
# npm — provenance + Trusted Publishers
grep -rE "--provenance|npm publish|trusted.*publish" .github/workflows/ package.json 2>/dev/null
# 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)
--provenanceflag onnpm publishAND Trusted Publishers (OIDC) — no long-livedNPM_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
# 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)
# Base image — prefer distroless / Chainguard / Wolfi / scratch
grep "^FROM" Dockerfile* 2>/dev/null | head -10
# RUN as non-root?
grep "^USER" Dockerfile* 2>/dev/null
# Trivy scan
command -v trivy >/dev/null && trivy fs --severity HIGH,CRITICAL --no-progress . 2>&1 | head -60 || echo "MISSING: trivy (brew install trivy)"
# 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)