Просмотр исходного кода

feat: add supply-chain-defense skill, rule, and pre-install scan hook

Behavioural-first defense against the 2026 supply-chain worm campaign
(Shai-Hulud / Mini Shai-Hulud). Proactive sibling to the reactive
security-ops: npm audit / pip-audit are advisory-driven and blind in the
window before an advisory exists, so this leans on Socket.dev behavioural
scanning (free CLI + no-key depscore MCP), a pre-install-scan.sh PreToolUse
hook (advisory by default, SUPPLY_CHAIN_BLOCK=1 for a hard gate), stale-OIDC
audit, a 7-day dependency cooldown, and a read-only self-integrity scan for
worm persistence hooks injected into Claude Code / VS Code / MCP configs.

Ships rules/supply-chain.md (global doctrine), four references (threat model,
Socket CLI/MCP/pricing, hardening checklist, free/OSS tooling landscape) and
three Axiom-protocol scripts (integrity-audit, preinstall-check, exposure-check
with a seeded IOC catalog). Also adds the missing security-ops row to the
README skill table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 85d9d9b8ee51c8010e4b82cbd43582ff503087c5)
0xDarkMatter 1 неделя назад
Родитель
Сommit
c737598dc4

+ 4 - 1
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
   "version": "2.8.0",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 78 skills, 2 commands, 6 rules, 5 hooks, 13 output styles, modern CLI tools",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 79 skills, 2 commands, 7 rules, 6 hooks, 13 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -105,6 +105,7 @@
       "skills/scaffold",
       "skills/screenshot",
       "skills/security-ops",
+      "skills/supply-chain-defense",
       "skills/setperms",
       "skills/skill-creator",
       "skills/spawn",
@@ -129,6 +130,7 @@
       "rules/commit-style.md",
       "rules/naming-conventions.md",
       "rules/skill-agent-updates.md",
+      "rules/supply-chain.md",
       "rules/thinking.md",
       "rules/worktree-boundaries.md"
     ],
@@ -136,6 +138,7 @@
       "hooks/pre-commit-lint.sh",
       "hooks/post-edit-format.sh",
       "hooks/dangerous-cmd-warn.sh",
+      "hooks/pre-install-scan.sh",
       "hooks/check-mail.sh",
       "hooks/enforce-uv.sh"
     ],

Разница между файлами не показана из-за своего большого размера
+ 6 - 2
README.md


+ 1 - 0
hooks/README.md

@@ -10,6 +10,7 @@ Claude Code hooks allow you to run custom scripts at key workflow points.
 | `post-edit-format.sh` | PostToolUse | Auto-format files after Write/Edit (Prettier, Ruff, gofmt, rustfmt) |
 | `dangerous-cmd-warn.sh` | PreToolUse | Block destructive commands (force push, rm -rf, DROP TABLE, etc.) |
 | `enforce-uv.sh` | PreToolUse | Enforce uv over pip/bare tools in uv-managed projects (`pip install` → `uv add`, bare `pytest`/`ruff`/`mypy` → `uv run`) |
+| `pre-install-scan.sh` | PreToolUse | Advisory on dependency installs (npm/pnpm/yarn/bun/pip/uv/poetry/composer/gem/cargo) — route through Socket, respect the release-age cooldown. Advisory by default; `SUPPLY_CHAIN_BLOCK=1` makes it a hard gate. Exempts `npm ci`/`--frozen-lockfile` and already-wrapped `socket` commands. |
 | `check-mail.sh` | PreToolUse | Check for unread pigeon pmail via signal file (zero-cost when empty) |
 
 ## Configuration

+ 96 - 0
hooks/pre-install-scan.sh

@@ -0,0 +1,96 @@
+#!/bin/bash
+# hooks/pre-install-scan.sh
+# PreToolUse hook — surfaces supply-chain hygiene before a dependency install runs.
+# Matcher: Bash
+#
+# The 2026 worm campaign (Shai-Hulud / Mini Shai-Hulud) executes via package
+# lifecycle scripts (postinstall, sdist setup.py) the moment you install, and
+# poisons brand-new releases that are pulled before any advisory exists. This hook
+# recognises install/add verbs and reminds you to scan + respect the release-age
+# cooldown, routing through the Socket CLI when available.
+#
+# Configuration in .claude/settings.json:
+# {
+#   "hooks": {
+#     "PreToolUse": [{
+#       "matcher": "Bash",
+#       "hooks": ["bash hooks/pre-install-scan.sh $TOOL_INPUT"]
+#     }]
+#   }
+# }
+#
+# Behaviour:
+#   Default          → ADVISORY. Prints guidance, exits 0 (command proceeds).
+#   SUPPLY_CHAIN_BLOCK=1 → HARD GATE. Exits 2 (command blocked) so you scan first.
+#
+# Exit codes:
+#   0 = allow (not an install, already wrapped, or advisory mode)
+#   2 = block with message (install verb matched AND SUPPLY_CHAIN_BLOCK=1)
+
+INPUT="$1"
+# Modern Claude Code delivers the tool call as JSON on stdin
+# ({"tool_input":{"command":"..."}}); older configs pass it as $TOOL_INPUT/$1.
+# Support both so the hook works regardless of harness version.
+if [[ -z "$INPUT" && ! -t 0 ]]; then
+  RAW="$(cat 2>/dev/null)"
+  if [[ -n "$RAW" ]] && command -v jq >/dev/null 2>&1; then
+    INPUT="$(printf '%s' "$RAW" | jq -r '.tool_input.command // .tool_input // empty' 2>/dev/null)"
+  fi
+  [[ -z "$INPUT" ]] && INPUT="$RAW"
+fi
+[[ -z "$INPUT" ]] && exit 0
+
+# Already routed through the behavioural scanner — let it through silently.
+echo "$INPUT" | grep -qE '\bsocket\s+(npm|npx|scan|ci|package)\b' && exit 0
+
+# Lockfile-pinned installs are the safer path we recommend — don't nag them.
+echo "$INPUT" | grep -qE '\bnpm\s+ci\b|--frozen-lockfile|--locked\b' && exit 0
+
+# ─── Recognise ecosystem install/add verbs ─────────────────────────────────
+ECO=""
+SAFE=""
+if   echo "$INPUT" | grep -qE '\b(npm|pnpm)\s+(install|i|add)\b'; then
+  ECO="npm";  SAFE="socket npm install <pkg>   # or: socket wrapper on"
+elif echo "$INPUT" | grep -qE '\byarn\s+(add|install)\b'; then
+  ECO="npm";  SAFE="socket npm install <pkg>   # yarn has no socket wrapper"
+elif echo "$INPUT" | grep -qE '\bbun\s+(add|install)\b'; then
+  ECO="npm";  SAFE="socket scan create .       # bun has no socket wrapper"
+elif echo "$INPUT" | grep -qE '\b(pip|pip3)\s+install\b'; then
+  ECO="pypi"; SAFE="socket scan create .       # no socket pip wrapper exists"
+elif echo "$INPUT" | grep -qE '\buv\s+(add|pip\s+install)\b'; then
+  ECO="pypi"; SAFE="socket scan create .       # scan the manifest after uv add"
+elif echo "$INPUT" | grep -qE '\bpoetry\s+add\b'; then
+  ECO="pypi"; SAFE="socket scan create ."
+elif echo "$INPUT" | grep -qE '\bcomposer\s+(require|install)\b'; then
+  ECO="composer"; SAFE="socket scan create ."
+elif echo "$INPUT" | grep -qE '\bgem\s+install\b'; then
+  ECO="rubygems"; SAFE="socket scan create ."
+elif echo "$INPUT" | grep -qE '\bcargo\s+(add|install)\b'; then
+  ECO="cargo"; SAFE="socket scan create ."
+else
+  exit 0
+fi
+
+# ─── Compose the advisory ──────────────────────────────────────────────────
+HAS_SOCKET=0; command -v socket >/dev/null 2>&1 && HAS_SOCKET=1
+
+echo "SUPPLY CHAIN: dependency install detected (${ECO})."
+echo "Lifecycle scripts run on install — the 2026 worm vector. Before proceeding:"
+echo "  1. Behavioural scan (not just npm audit / pip-audit — those miss fresh malware)."
+echo "  2. Respect the 7-day release-age cooldown for anything that hits prod/CI."
+if [[ "$HAS_SOCKET" -eq 1 ]]; then
+  echo "  Route it through Socket:  ${SAFE}"
+else
+  echo "  Socket CLI not installed (free):  npm install -g socket"
+  echo "  Or add depscore MCP (no key):  claude mcp add --transport http socket-mcp https://mcp.socket.dev/"
+fi
+echo "  Cooldown check:  bash skills/supply-chain-defense/scripts/preinstall-check.sh <pkg>"
+
+if [[ "${SUPPLY_CHAIN_BLOCK:-0}" == "1" ]]; then
+  echo ""
+  echo "Blocked (SUPPLY_CHAIN_BLOCK=1). Scan the package, then re-run via the Socket"
+  echo "wrapper or unset SUPPLY_CHAIN_BLOCK after you've confirmed it's safe."
+  exit 2
+fi
+
+exit 0

+ 81 - 0
rules/supply-chain.md

@@ -0,0 +1,81 @@
+# Supply Chain Hygiene — behavioural-first dependency defense
+
+Companion to the [`supply-chain-defense`](../skills/supply-chain-defense/SKILL.md) skill
+(the full playbook + scripts) and [`security-ops`](../skills/security-ops/SKILL.md)
+(reactive CVE auditing). This file is the *directive* — what to do every time a
+dependency enters the tree, in any project.
+
+## The rule
+
+**Treat every dependency add or version bump as untrusted until it has been
+behaviourally scanned. CVE/advisory tools are necessary but not sufficient — they
+report yesterday's known-bad, and the active 2026 threat is a worm that publishes
+and self-propagates inside the 30-minute-to-6-hour window *before* any advisory
+exists.**
+
+Three non-negotiables:
+
+1. **Never install an unconstrained, just-published version into anything that
+   executes** — build, CI, dev shell, or production. Lifecycle scripts
+   (`postinstall`, `prepare`, sdist `setup.py`) run code the moment you install.
+2. **A behavioural scan gates the merge, not the CVE database.** `npm audit` /
+   `pip-audit` passing is not a green light. Behaviour is.
+3. **The blast radius includes this machine.** The 2026 worm family
+   (Shai-Hulud / Mini Shai-Hulud) writes persistence hooks into Claude Code and
+   VS Code settings to survive reboots. A poisoned `npm install` is an attack on
+   your editor, your credentials, and your publish tokens — not just one package.
+
+## Why this matters
+
+Over 90 days in 2026: axios (100M weekly downloads, NK state actor, stolen npm
+token), Microsoft's official `durabletask` PyPI SDK (zero provenance, credential
+stealer), 323 packages poisoned in a 22-minute automated burst, and GitHub itself
+losing 3,800 internal repos to one poisoned VS Code extension on one laptop. The
+worm source is public on BreachForums; copycats are live. Every control that
+depends on maintainer hygiene (2FA, lockfile-on-clean, even Sigstore/SLSA
+provenance) has already been bypassed in the wild. The only control that closed
+the gap was behavioural analysis of what the package *does* within seconds of
+publication.
+
+See `supply-chain-defense/references/threat-model.md` for the full timeline and IOCs.
+
+## Directives — apply on every dependency touch
+
+| Situation | Directive |
+|---|---|
+| Adding or bumping a dependency | Run a behavioural scan (`socket scan` / depscore MCP) and surface the verdict **before** merge. Don't merge on a CVE-clean signal alone. |
+| Production / build dependency | Enforce a **7-day cooldown** after a release before pulling it. The axios poisoned versions were live ~3 hours; a 7-day lag lets the ecosystem detect and remediate first. |
+| Any `install` / `add` command | Prefer the Socket wrapper (`socket npm …`, `socket npx …`) when available, so a risky install is intercepted before lifecycle scripts execute. |
+| Lifecycle scripts | Where a project doesn't need build hooks, disable them: `npm config set ignore-scripts true` / pnpm `enable-pre-post-scripts=false`. It removes the `postinstall` execution vector outright — the cheapest mitigation that exists. |
+| Lockfiles | Commit them. Pin exact versions for anything that runs in CI or prod. A pin only protects you if it *pre-dates* the compromise and you never run unconstrained installs. |
+| CI/CD workflows | Audit GitHub Actions for stale OIDC trust federation to npm/PyPI. Revoke any publish trust no longer needed — this is the exact entry point Mini Shai-Hulud abused (orphaned commit, live OIDC federation, minted token). Run `zizmor` to catch the `pull_request_target` + OIDC misconfigs statically; add `step-security/harden-runner` for runtime egress control. |
+| Publish tokens | Prefer short-lived OIDC over long-lived npm/PyPI tokens. Audit and tighten who has standing publish access. |
+| After any install on this machine | Be alert to new/modified entries in `.claude/settings.json`, `.claude.json`, or VS Code `settings.json` — unexplained hooks/`mcpServers`/startup entries are a persistence IOC. |
+
+## Self-check before generating install/setup commands
+
+Before writing any `npm install`, `pip install`, `uv add`, `composer require`, etc.
+into a README, Dockerfile, CI workflow, or shell snippet:
+
+- Never recommend day-zero pulls (`npm install <pkg>@latest` of a brand-new
+  release) for production paths. Pin a version that has aged past the cooldown.
+- Where a behavioural scanner is in play, route the command through it
+  (`socket npm install …`) rather than raw `npm`.
+- If the user is wiring CI publish, default to OIDC trusted publishing, not a
+  stored long-lived token.
+
+## When the playbook is needed
+
+For the full operational workflow — trialling Socket.dev, the wrapper setup, the
+GitHub PR app, the depscore MCP server for Claude Code, the OIDC audit, token
+rotation, VS Code extension audit, the self-integrity scan that detects injected
+persistence hooks, and the wider free/OSS toolset (GuardDog, OSV-Scanner, zizmor,
+Harden-Runner, lockfile-lint) — **invoke the `supply-chain-defense` skill.** Everything
+needed to defend against this campaign works at $0.
+
+## Cross-reference
+
+- `~/.claude/skills/supply-chain-defense/SKILL.md` — full playbook + scripts
+- `~/.claude/skills/security-ops/SKILL.md` — reactive CVE/SAST/auth audit
+- `~/.claude/hooks/pre-install-scan.sh` — PreToolUse advisory on install commands
+- `~/.claude/rules/cli-tools.md` — modern tool preferences (uv, fd, rg)

Разница между файлами не показана из-за своего большого размера
+ 324 - 0
skills/supply-chain-defense/SKILL.md


+ 0 - 0
skills/supply-chain-defense/assets/.gitkeep


+ 24 - 0
skills/supply-chain-defense/assets/exposure-catalog.json

@@ -0,0 +1,24 @@
+{
+  "schema_version": "v0.1.0",
+  "_about": "IOC exposure catalog for exposure-check.py. Format borrowed from Perplexity's Bumblebee (docs/schema/v0.1.0/exposure-catalog.schema.json, Apache-2.0). Seed it from advisories: each entry is a known-bad {ecosystem, package, versions[]}. Only add versions you can cite from a real advisory — a fabricated entry is a false match waiting to happen. ecosystem is one of: npm, pypi, go, rubygems, packagist, mcp, editor-extension, browser-extension.",
+  "entries": [
+    {
+      "id": "AXIOM-SC-2026-AXIOS",
+      "name": "axios RAT delivery (Sapphire Sleet)",
+      "ecosystem": "npm",
+      "package": "axios",
+      "versions": ["1.14.1", "0.30.4"],
+      "severity": "critical",
+      "note": "2026-03-30/31: maintainer account compromised, two backdoored versions published in a 39-min window, live ~3h. Cross-platform RAT delivery. Attributed to Sapphire Sleet (NK)."
+    },
+    {
+      "id": "EXAMPLE-TEMPLATE-DO-NOT-MATCH",
+      "name": "Format example — replace with real advisory IOCs",
+      "ecosystem": "pypi",
+      "package": "example-bad-package-that-does-not-exist",
+      "versions": ["0.0.0-never"],
+      "severity": "info",
+      "note": "Template only. When an advisory names a package (e.g. the Microsoft durabletask PyPI compromise, the TanStack/AntV npm waves), add an entry here with the exact versions from the advisory. This example package name is intentionally non-existent so it never matches."
+    }
+  ]
+}

+ 199 - 0
skills/supply-chain-defense/references/hardening-checklist.md

@@ -0,0 +1,199 @@
+# Supply Chain Hardening Checklist
+
+Step-by-step procedures for the hygiene and self-integrity layers. Run top to
+bottom for a full hardening pass, or jump to a section. The first three are
+read-only; the rest change live state — confirm before acting.
+
+---
+
+## 1. Self-integrity scan (read-only — run first)
+
+Detect whether a worm has already injected persistence into this machine.
+
+```bash
+bash scripts/integrity-audit.sh
+```
+
+Manual equivalents if you want to eyeball it:
+
+```bash
+# Claude Code config — look for hooks / mcpServers you didn't add
+cat ~/.claude/settings.json ~/.claude/settings.local.json ~/.claude.json 2>/dev/null
+
+# VS Code user settings — look for startup tasks, autorun, unexpected entries
+#   macOS:   ~/Library/Application Support/Code/User/settings.json
+#   Linux:   ~/.config/Code/User/settings.json
+#   Windows: %APPDATA%\Code\User\settings.json
+```
+
+**If you find an entry you didn't add → treat as an incident:** isolate the
+machine, rotate every credential reachable from it (cloud, npm/PyPI, GitHub, AI
+API keys), and investigate before continuing. Do not just delete the hook and
+move on — the worm's first act was credential theft.
+
+---
+
+## 2. VS Code extension audit (read-only)
+
+```bash
+code --list-extensions --show-versions
+```
+
+For each extension, check publication recency on the Marketplace. **Pause anything
+published in the last 7 days from a non-verified publisher.** Remember Nx Console:
+verified publisher, 2.2M installs, still backdoored — verified status is not a
+safety guarantee, recency + behaviour is.
+
+Disable rather than uninstall while triaging, so you can compare versions:
+
+```bash
+code --disable-extension <publisher.extension>
+```
+
+---
+
+## 3. Stale OIDC trust audit (read-only)
+
+The Mini Shai-Hulud entry point. Find every workflow that can mint a publish
+token:
+
+```bash
+# Workflows requesting an OIDC token
+rg -l 'id-token:\s*write' .github/workflows/
+
+# Publish steps that consume it
+rg -n 'npm publish|pypi|twine upload|trusted.?publish|setup-node.*registry-url' .github/workflows/
+```
+
+`scripts/integrity-audit.sh` reports these automatically. For richer static
+analysis — `pull_request_target` misuse, template injection, over-broad token
+scopes — run **zizmor** (the audit script invokes it automatically if installed):
+
+```bash
+uv tool install zizmor
+zizmor .github/workflows/
+```
+
+For each workflow with publish trust, ask: **is this still needed?** A federation
+left configured on an orphaned/archived workflow is pure attack surface.
+
+---
+
+## 4. Revoke stale OIDC + rotate tokens (changes live state — confirm)
+
+For trust relationships you no longer need:
+
+- **npm:** Settings → trusted publishers → remove the GitHub workflow binding.
+  Audit Access Tokens; delete unused; prefer granular automation tokens with the
+  narrowest scope, or move to trusted publishing entirely.
+- **PyPI:** Project → Settings → Publishing → remove stale trusted publishers.
+  Revoke unused API tokens.
+- **Workflow side:** drop `id-token: write` from `permissions:` where publishing
+  no longer happens.
+
+Prefer **short-lived OIDC trusted publishing** over long-lived tokens everywhere.
+Rotate any long-lived publish token now; keep the set of accounts with standing
+publish access as small as the team allows.
+
+> T3 — rotating a token or removing trust can break a running pipeline. Confirm
+> the workflow is genuinely stale, and have the replacement (OIDC) ready before
+> revoking the old path.
+
+---
+
+## 5. Dependency pinning + cooldown policy
+
+- Commit lockfiles (`package-lock.json`, `pnpm-lock.yaml`, `composer.lock`,
+  `uv.lock`, `Cargo.lock`).
+- Pin exact versions for anything that runs in CI or production.
+- **7-day cooldown:** do not auto-update production dependencies until a release
+  has aged at least a week. Encode it in Renovate/Dependabot:
+
+```jsonc
+// renovate.json — hold prod deps for 7 days after release
+{
+  "packageRules": [
+    { "matchDepTypes": ["dependencies"], "minimumReleaseAge": "7 days" }
+  ]
+}
+```
+
+Rationale: the axios poisoned versions were live ~3 hours. A 7-day lag gives the
+ecosystem (and Socket's behavioural feed) time to detect and remediate before you
+pull.
+
+Check publish age ad-hoc before any add:
+
+```bash
+bash scripts/preinstall-check.sh axios react@19.0.0
+```
+
+---
+
+## 6. Wrap installs (layer 2)
+
+Route installs through the behavioural scanner so lifecycle scripts are gated:
+
+```bash
+socket npm install <pkg>     # one-off
+socket wrapper on            # workspace-wide alias of npm/npx → Socket
+```
+
+Reinforce inside Claude Code with the `pre-install-scan.sh` hook (advisory; set
+`SUPPLY_CHAIN_BLOCK=1` for a hard gate). See SKILL.md → Hook setup.
+
+Two more cheap, free hardening levers:
+
+```bash
+# Disable lifecycle scripts where you don't need build hooks (removes the
+# postinstall vector entirely). Allow specific packages back via npm rebuild.
+npm config set ignore-scripts true        # pnpm: enable-pre-post-scripts=false
+
+# Validate the committed lockfile points only at the real registry over https.
+npx lockfile-lint --path package-lock.json --allowed-hosts npm --validate-https
+```
+
+---
+
+## 7. Behavioural scanning in PRs + CI (layer 1)
+
+- Install the Socket **GitHub app** on the repo (free tier, private repos
+  included). It comments a risk report on any dependency-changing PR.
+- In CI: `socket ci` enforces your org's policy and fails the build on a flagged
+  package. Free-tier scan cap is 1,000/month.
+- Add free/OSS engines for breadth and a runtime backstop:
+
+```bash
+osv-scanner scan -r .            # broad CVE coverage (Google OSV.dev), all manifests
+guarddog npm verify package-lock.json   # local behavioural second opinion (Datadog)
+```
+
+```yaml
+# .github/workflows/*.yml — runtime egress control on the runner (free for public repos)
+- uses: step-security/harden-runner@v2
+  with:
+    egress-policy: audit         # tighten to 'block' + allowlist once baseline is known
+```
+
+See `references/tooling-landscape.md` for the full when-to-use-which matrix.
+
+---
+
+## 8. Client-facing posture (optional but increasingly asked)
+
+For security questionnaires and proposals, "we run behavioural package scanning on
+every dependency change, enforce a release-age cooldown on production
+dependencies, and audit CI publish trust" is a credible, specific answer. Expect
+procurement and insurance to ask harder supply-chain questions through 2027.
+
+---
+
+## Quick pass (the 1-hour version)
+
+1. `bash scripts/integrity-audit.sh` — confirm the machine is clean.
+2. Install the Socket GitHub app on your lowest-risk repo.
+3. `claude mcp add --transport http socket-mcp https://mcp.socket.dev/` — free
+   depscore in Claude Code, no key.
+4. Add `minimumReleaseAge: 7 days` to Renovate/Dependabot for prod deps.
+5. Skim `rg -l 'id-token:\s*write' .github/workflows/` and note anything stale to
+   revoke later.

+ 138 - 0
skills/supply-chain-defense/references/socket-cli.md

@@ -0,0 +1,138 @@
+# Socket.dev — CLI, MCP, and pricing reference
+
+Accurate command surface as of May 2026. Distilled from
+[docs.socket.dev/docs/socket-cli](https://docs.socket.dev/docs/socket-cli),
+[socket.dev/pricing](https://socket.dev/pricing), and
+[github.com/SocketDev/socket-mcp](https://github.com/SocketDev/socket-mcp). Verify
+against the live docs before quoting versions — Socket iterates fast.
+
+## Is it free? — yes, and free covers this threat
+
+The **Socket CLI is open-source and free to install and run.** A **free account**
+($0) is enough to defend against the 2026 worm campaign. Paid tiers buy
+noise-reduction (reachability) and scale, not the core malware detection.
+
+| Capability | Free ($0) | Team ($25/dev/mo) | Business ($50/dev/mo) | Enterprise |
+|---|---|---|---|---|
+| `socket` CLI | ✅ | ✅ | ✅ | ✅ |
+| Malware blocking + AI behavioural analysis, 70+ risk types | ✅ | ✅ | ✅ | ✅ |
+| Private repos | ✅ unlimited | ✅ | ✅ | ✅ |
+| GitHub app (PR risk comments) | ✅ | ✅ | ✅ | ✅ |
+| **depscore MCP (no API key)** | ✅ | ✅ | ✅ | ✅ |
+| Scans / month | 1,000 | 5,000 | unlimited | unlimited |
+| Members | 3 | 10 | unlimited | unlimited |
+| Repository labels | 1 | 3 | unlimited | unlimited |
+| Reachability analysis (cuts ~60% CVE false positives) | ❌ | ✅ | ✅ | ✅ |
+| SSO/SAML, SBOM import/export, compliance | ❌ | ❌ | ✅ | ✅ |
+| GitHub Actions + AI-model scanning | ❌ | ❌ | ✅ | ✅ |
+| Function-level reachability (~90% CVE reduction) | ❌ | ❌ | ❌ | ✅ |
+| GitLab / Bitbucket / Azure DevOps, SCIM, audit logs | ❌ | ❌ | ❌ | ✅ |
+
+> **Open-source projects:** "Socket is and will always be free to use for
+> open-source." Qualifying OSS teams can request a **complimentary Team account**
+> (the larger scan cap + reachability) for free.
+
+**Recommendation:** start on Free. The 1,000-scan cap and 3 members are generous
+for a small team trialling one repo. Move to Team only when CVE false-positive
+noise (reachability) or seat count justifies $25/dev.
+
+## Installation
+
+```bash
+npm install -g socket          # CLI is published as the `socket` npm package
+```
+
+> ⚠️ Terminology correction: older write-ups (and the originating briefing) call
+> the wrappers "safe npm" / "safe pip". The current CLI is `npm install -g socket`
+> then `socket npm …` / `socket wrapper on`. There is **no documented `socket pip`
+> wrapper** — for PyPI use `socket scan` against the manifest + the GitHub app +
+> the depscore MCP rather than expecting a pip wrapper.
+
+## Authentication
+
+```bash
+socket login                   # interactive; stores API token locally
+socket logout                  # remove stored credentials
+SOCKET_SECURITY_API_TOKEN=xyz socket <command>   # non-interactive / CI
+```
+
+The depscore MCP **remote** needs no login at all (see below).
+
+> Verified: `socket package score` and `socket scan` **require a token** — without
+> `socket login` they exit 2 with "This command requires a Socket API token". The
+> login/account is free, but the CLI is not zero-auth. If you want behavioural
+> scoring with *no* account at all, use the depscore MCP remote.
+
+## Core commands
+
+| Command | Purpose |
+|---|---|
+| `socket scan create [path]` | Generate a behavioural security scan of a project's manifests |
+| `socket scan list` | List existing scans |
+| `socket scan --report` | Validate a scan against your org's security/license policies |
+| `socket scan github` | GitHub-specific scanning |
+| `socket package score <ecosystem> <name> [version]` | Retrieve a package's security score |
+| `socket ci` | Run policy-enforced scanning in a CI pipeline |
+| `socket npm` / `socket npx` | Wrapper that routes npm/npx through Socket before lifecycle scripts run |
+| `socket wrapper on` / `off` | Toggle workspace-wide aliasing of `npm`/`npx` through Socket |
+| `socket raw-npm` / `socket raw-npx` | Bypass the wrapper for one invocation |
+| `socket fix` | Apply security updates |
+| `socket optimize` | Apply package overrides |
+| `socket threat-feed` | Real-time threat intelligence feed |
+| `socket analytics` | Security-health dashboards |
+| `socket manifest` / `socket manifest cdxgen` | Manifest operations / generate via cdxgen |
+| `socket organization` / `socket repository` / `socket audit-log` | Org / repo / audit-log management |
+
+### Common flags
+
+| Flag | Effect |
+|---|---|
+| `--json` | JSON output (pipe to `jq`) |
+| `--markdown` | Markdown output |
+| `--config '<JSON>'` | Override config for this run |
+| `--dry-run` | Validate inputs without executing |
+| `--help` / `--version` | Per-command help / CLI version |
+
+## depscore MCP server — the Claude Code win (free, no key)
+
+The Socket MCP server exposes a **`depscore`** tool so an AI assistant can query a
+package's behavioural/quality score *before* suggesting you add it. Two variants:
+
+### Remote (recommended — zero auth, zero install)
+
+```bash
+claude mcp add --transport http socket-mcp https://mcp.socket.dev/
+```
+
+No API key. This is the fastest way to give Claude Code behavioural package
+scoring.
+
+### Local / self-hosted (stdio, needs a free API key)
+
+```bash
+claude mcp add socket-mcp -e SOCKET_API_KEY="your-api-key-here" \
+  -- npx -y @socketsecurity/mcp@latest
+```
+
+The only required permission scope for the API key is `packages:list` (lets the
+server query package metadata for scores). Create the key from a free Socket
+account.
+
+> Package: [`@socketsecurity/mcp`](https://socket.dev/npm/package/@socketsecurity/mcp).
+> Note the irony — you can have Socket score its own MCP package before installing
+> it.
+
+## GitHub app (layer 1 for PRs)
+
+Install Socket as a GitHub App on a repo. It auto-evaluates every change to
+`package.json` and other manifests; when a PR adds a dependency it leaves a comment
+indicating the risk profile. Works on the free tier including private repos
+(subject to the 1,000-scan/month cap).
+
+## Sources
+
+- CLI guide: <https://docs.socket.dev/docs/socket-cli>
+- Pricing: <https://socket.dev/pricing>
+- MCP server: <https://github.com/SocketDev/socket-mcp>
+- MCP for Claude Desktop: <https://docs.socket.dev/docs/socket-mcp-for-claude-desktop>
+- GitHub app: <https://docs.socket.dev/docs/socket-for-github>

Разница между файлами не показана из-за своего большого размера
+ 78 - 0
skills/supply-chain-defense/references/threat-model.md


+ 297 - 0
skills/supply-chain-defense/references/tooling-landscape.md

@@ -0,0 +1,297 @@
+# Supply Chain Tooling Landscape
+
+Socket.dev is the behavioural-scanning leader, but a single vendor is not defense
+in depth. This file maps the wider ecosystem — **almost all free / open-source** —
+onto the four layers (detection, interception, hygiene, self-integrity) so you can
+reach for the right tool per concern and avoid mono-sourcing.
+
+## Contents
+
+1. [The picture in one table](#the-picture-in-one-table)
+2. [Layer 1 — detection](#layer-1--detection)
+3. [Layer 2 — interception](#layer-2--interception)
+4. [Layer 3 — hygiene](#layer-3--hygiene)
+5. [Layer 4 — self-integrity](#layer-4--self-integrity)
+6. [When to use which](#when-to-use-which)
+7. [How the controls interact](#how-the-controls-interact)
+8. [Minimum viable set + dependency note](#minimum-viable-set--dependency-note)
+
+## The picture in one table
+
+| Tool | Layer | Cost | Engine | Covers |
+|---|---|---|---|---|
+| **Socket.dev** | 1 | Free CLI + $0 tier; paid for scale | Behavioural (static + LLM), hosted feed | npm, PyPI, Go, Maven, RubyGems |
+| **GuardDog** (Datadog) | 1 | Free / OSS | Behavioural heuristics + Semgrep rules, local | npm, PyPI, GitHub Actions |
+| **OSV-Scanner** (Google) | 1 | Free / OSS | CVE/advisory (OSV.dev) | ~broad: npm, PyPI, Go, Maven, crates, …|
+| **`npm audit` / `pip-audit`** | 1 | Free / built-in | CVE/advisory | npm / PyPI |
+| **`npm audit signatures`** | 1 | Free / built-in | Registry signature + provenance check | npm |
+| **`ignore-scripts` config** | 2 | Free / built-in | Disables lifecycle scripts | npm, pnpm, yarn |
+| **`socket` wrapper** | 2 | Free | Intercepts install pre-execution | npm / npx |
+| **lockfile-lint** | 2 | Free / OSS | Lockfile URL/host/https/integrity validation | npm, yarn |
+| **zizmor** (Trail of Bits) | 3 | Free / OSS | Static analysis of GitHub Actions | GHA workflows |
+| **Harden-Runner** (StepSecurity) | 2/3 | Free for public repos | Runtime egress monitoring/blocking on CI runners | GitHub Actions runners |
+| **gitleaks** | 3 | Free / OSS | Secret scanning (token leak detection) | any repo |
+| **Trivy** (Aqua) | 1/3 | Free / OSS | SCA + IaC + secrets + container | many |
+| **Bumblebee** (Perplexity) | 4 | Free / OSS | On-disk inventory + IOC catalog match | npm/pypi/go/rubygems/composer + editor & browser extensions + MCP (**macOS/Linux only**) |
+| **`exposure-check.py`** (this skill) | 4 | Free | IOC catalog match, cross-platform | npm + pypi (runs on Windows, where Bumblebee can't) |
+
+> The whole table reinforces the thesis: **you can stand up real defense in depth
+> at $0.** Paid tiers buy noise-reduction and scale, not the core capability.
+
+## Layer 1 — detection
+
+### Socket.dev (lead behavioural)
+
+Hosted engine that clones registries in real time and runs static + LLM analysis
+on every new package within seconds of publication. Free CLI, free $0 account
+tier, no-key depscore MCP. Full command surface and pricing in
+`references/socket-cli.md`. **Use as the primary PR/merge gate and the Claude Code
+package-scoring source.**
+
+### GuardDog (Datadog) — the free local behavioural second opinion
+
+Open-source CLI using source-code heuristics (Semgrep rules + metadata checks) to
+flag malicious packages: suspicious `postinstall`/`setup.py` code, base64-encoded
+exec, network exfiltration, obfuscation, npm/PyPI metadata anomalies. Runs fully
+locally — no account, no telemetry.
+
+```bash
+uv tool install guarddog         # or: pipx install guarddog
+guarddog npm scan <package>      # scan a published package (registry)
+guarddog npm scan ./local-pkg --exit-non-zero-on-finding   # scan a local dir; exit 1 on findings
+guarddog pypi scan <package>
+guarddog npm verify package-lock.json   # scan a whole lockfile
+guarddog github_action scan .    # workflow heuristics
+```
+
+**Do you need it alongside Socket?** For most single-dev / small-team setups,
+**no — Socket is the daily driver** and GuardDog is redundant on the routine
+"score before I add it" path. Reach for GuardDog *situationally*, not in parallel:
+
+- **Privacy / air-gapped** — analysis is local; package names never leave the
+  machine (Socket is a hosted service).
+- **Second, auditable opinion** on a specific suspicious package — open-source
+  Semgrep/YARA rules you can read, vs Socket's proprietary + LLM engine.
+- **No-account / offline CI** where a hosted scanner isn't permitted.
+
+What Socket has that GuardDog doesn't: a real-time registry feed that flags fresh
+malware before advisories exist. What GuardDog has: local execution and auditable
+rules. (Verified: GuardDog caught `npm-exec-base64`, `npm-serialize-environment`,
+and `npm-exfiltrate-sensitive-data` with `file:line` citations on a crafted
+package.)
+
+> ⚠️ **Windows gotchas (verified):**
+> 1. GuardDog reads its rule YAMLs without forcing UTF-8 and crashes on cp1252 —
+>    run it with `PYTHONUTF8=1 guarddog …` (or set `PYTHONUTF8=1` for the session).
+> 2. Its source-code rules (the behavioural ones — base64-exec, exfil) shell out to
+>    **`semgrep`**, which must be on PATH (`uv tool install semgrep`). **Without it,
+>    GuardDog prints `Found 0 potentially malicious indicators` and exits 0** with
+>    only a buried "Some rules failed to run" warning — a dangerous *false-clean*.
+>    Always confirm semgrep is present before trusting a clean GuardDog result.
+
+### OSV-Scanner (Google) — broad CVE coverage
+
+Open-source scanner against [OSV.dev](https://osv.dev), which aggregates advisories
+across far more ecosystems than `npm audit` alone. CVE-based (so it shares the
+advisory-lag blind spot), but a stronger CVE layer than per-ecosystem audit tools.
+
+```bash
+# install: scoop install osv-scanner | brew install osv-scanner |
+#          go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest  (note the /v2)
+osv-scanner scan --lockfile requirements.txt   # also: package-lock.json, go.mod, Cargo.lock, …
+osv-scanner scan -r .            # recursive, all manifests; exit 1 when vulns found
+```
+
+**Use as** the CVE-side complement to `security-ops` — broader and faster than
+`npm audit`/`pip-audit`. Pair with a behavioural scanner; do not rely on it alone.
+
+### `npm audit signatures`
+
+Verifies that installed packages match the registry's signatures and (where
+present) provenance attestations. Remember the threat model: valid provenance was
+**forged** in 2026, so a pass is necessary-not-sufficient. Cheap to run; treat as
+one signal among several.
+
+## Layer 2 — interception
+
+### `ignore-scripts` — the cheapest mitigation that exists
+
+Lifecycle scripts (`preinstall`/`postinstall`/`prepare`, sdist `setup.py`) are the
+worm's execution vector. Disabling them removes it for projects that don't need
+them:
+
+```bash
+npm config set ignore-scripts true                 # npm, global
+# package.json / .npmrc:  ignore-scripts=true
+# pnpm (.npmrc):          enable-pre-post-scripts=false
+# yarn (.yarnrc.yml):     enableScripts: false
+```
+
+Trade-off: some packages legitimately need build steps (native modules). Allow them
+explicitly (`npm rebuild <pkg>` / pnpm `onlyBuiltDependencies`) rather than leaving
+scripts globally on.
+
+### lockfile-lint — detect lockfile injection
+
+Validates that resolved URLs in a lockfile point at the expected registry over
+https with integrity hashes — catches a tampered lockfile redirecting a package to
+an attacker host.
+
+```bash
+npx lockfile-lint --path package-lock.json --allowed-hosts npm --validate-https
+```
+
+### `socket` wrapper
+
+`socket npm install …` / `socket wrapper on` — intercepts a risky install before
+lifecycle scripts run. See `references/socket-cli.md`.
+
+## Layer 3 — hygiene
+
+### zizmor (Trail of Bits) — the OIDC/workflow auditor
+
+Open-source static analyzer for GitHub Actions. It detects exactly the class of
+misconfiguration Mini Shai-Hulud abused: dangerous `pull_request_target` triggers,
+over-broad `id-token`/token permissions, template injection, credential
+persistence, and cache-poisoning vectors.
+
+```bash
+uv tool install zizmor           # or: pipx install zizmor
+zizmor .github/workflows/        # audit all workflows
+zizmor --format sarif . > zizmor.sarif
+```
+
+**Use as** the engine behind the OIDC-audit workflow (replaces hand-rolled `rg`).
+`scripts/integrity-audit.sh` invokes `zizmor` automatically when it's installed.
+
+### Harden-Runner (StepSecurity) — runtime CI egress control
+
+A GitHub Action that instruments the runner to monitor (and optionally block)
+outbound network traffic, file writes, and process events. If a compromised
+dependency tries to exfiltrate the OIDC token or phone home to C2 during a CI run,
+Harden-Runner surfaces or blocks it. Free for public repositories.
+
+```yaml
+# .github/workflows/*.yml — first step in the job
+- uses: step-security/harden-runner@v2
+  with:
+    egress-policy: audit        # start in audit, tighten to 'block' with an allowlist
+```
+
+**Use when** your CI publishes or holds any credential — it's the runtime backstop
+for the token-theft vector that pinning and scanning don't cover.
+
+### gitleaks
+
+Secret scanning to catch leaked npm/PyPI/cloud tokens before they ship. Already
+used by `git-ops`' push gate. Relevant here for the token-rotation workflow.
+
+## Layer 4 — self-integrity + exposure response
+
+Two distinct questions here: "has the worm persisted on this machine?" and "do we
+already have a named-bad package installed?"
+
+### Persistence detection
+
+`scripts/integrity-audit.sh` (this skill) scans AI-tool configs (Claude Code +
+Desktop, Gemini, MCP host JSON) and editor settings (VS Code, Cursor, Windsurf,
+VSCodium) for injected persistence hooks/MCP servers, and flags workflows with live
+OIDC trust (running `zizmor` when present). The host-config map is drawn from
+Bumblebee's `docs/inventory-sources.md`.
+
+### Exposure response — Bumblebee + exposure-check.py
+
+When an advisory names a poisoned package + version, you need to know which
+machines/projects have it on disk *right now*.
+
+- **Bumblebee** (Perplexity, Apache-2.0, Go, **macOS/Linux**) is the fleet-scale
+  tool: a read-only inventory collector that walks lockfiles, package-manager
+  metadata, editor + browser extensions, and MCP host configs, emits NDJSON, and —
+  given an `--exposure-catalog` of known-bad `{ecosystem, package, versions[]}` —
+  flags exact matches. Built for incident response across many developer endpoints.
+
+  ```sh
+  go install github.com/perplexityai/bumblebee/cmd/bumblebee@latest   # Go 1.25+
+  bumblebee scan --profile deep --root "$HOME" --exposure-catalog ./catalog.json --findings-only
+  ```
+
+- **`scripts/exposure-check.py`** (this skill) is the **cross-platform local
+  equivalent** — it runs on Windows, where Bumblebee doesn't, and reuses Bumblebee's
+  exposure-catalog JSON shape so a catalog is portable between them. Narrower
+  coverage (npm + pypi), but the same "am I exposed to this advisory?" answer:
+
+  ```bash
+  python scripts/exposure-check.py --root ~/code --json | jq '.data.findings[]'
+  ```
+
+  Seed `assets/exposure-catalog.json` from advisories as incidents break (it ships
+  with cited 2026 IOCs). **Verified:** flags axios 1.14.1 in a lockfile with exit 10.
+
+Reach for Bumblebee at fleet scale on macOS/Linux; `exposure-check.py` for a quick
+local check anywhere including Windows.
+
+## When to use which
+
+- **Before adding a dependency** → Socket depscore (MCP/CLI) + optionally GuardDog
+  for an offline second opinion; `scripts/preinstall-check.sh` for release age.
+- **On every PR** → Socket GitHub app (behavioural) + OSV-Scanner (CVE breadth).
+- **At the install command** → `socket` wrapper or `ignore-scripts`; `lockfile-lint`
+  on the committed lockfile.
+- **Auditing CI** → zizmor (static workflow analysis) + Harden-Runner (runtime
+  egress). Rotate tokens; gitleaks for leaks.
+- **Checking this machine** → `scripts/integrity-audit.sh`.
+
+Mono-sourcing on any one tool recreates a single point of failure. The 2026 worms
+adapted to each defensive response in turn — layered, multi-engine coverage is the
+point.
+
+## How the controls interact
+
+These do **not** form a pipeline — nothing pipes one tool's output into another.
+They are independent verdicts at different points in a dependency's lifecycle, with
+deliberate redundancy at the two highest-value chokepoints.
+
+| Lifecycle stage | Control(s) | How they relate |
+|---|---|---|
+| Considering a package | Socket depscore (primary); GuardDog *situational* | **Overlapping** — Socket is the daily driver; add GuardDog only for offline/privacy/auditable second opinions, not a parallel daily run |
+| Is it too new? | `preinstall-check.sh` | Orthogonal — answers release age, not maliciousness |
+| Install runs | `ignore-scripts` / socket wrapper / `pre-install-scan.sh` | Alternatives at one point; `ignore-scripts` is the most aggressive (kills all lifecycle scripts) |
+| Lockfile committed | lockfile-lint | Orthogonal — validates the lock's resolved URLs, not the package contents |
+| PR opened | Socket app **+** OSV-Scanner | Complementary — behavioural vs CVE breadth. **OSV supersedes `npm audit`** (run one, not both) |
+| CI runs | zizmor **+** Harden-Runner | **Complementary, not redundant** — zizmor fixes the misconfigured door (static, pre-run); Harden-Runner alarms if someone walks through (runtime egress) |
+| This machine | `integrity-audit.sh` | Orthogonal — the victim side |
+
+**The only real integration:** `integrity-audit.sh` invokes `zizmor` when it's on
+PATH (and degrades to a weaker `rg` check, loudly, when it isn't).
+
+**The one conflict to plan for:** Harden-Runner's `egress-policy: block` will choke
+`socket ci`, installs, and anything that phones a registry — you must allowlist the
+package registry plus `api.socket.dev` / `mcp.socket.dev` when you tighten it. Start
+in `audit` mode, learn the baseline, then block with an allowlist.
+
+**Overlap summary:** redundant pairs (Socket/GuardDog, Socket/OSV at the PR) are
+intentional — different engines, different blind spots. Complementary pairs
+(zizmor/Harden-Runner) cover different phases. OSV is an upgrade over `npm audit`,
+not an addition to it.
+
+## Minimum viable set + dependency note
+
+**This is a menu, not a mandatory stack.** Running all of it on every project is
+overkill. The minimum viable set — all free, ~1 hour to stand up — is four things:
+
+1. depscore MCP in Claude Code (`claude mcp add --transport http socket-mcp https://mcp.socket.dev/`)
+2. Socket GitHub app on the repo
+3. Renovate `minimumReleaseAge: 7 days` on production deps
+4. `npm config set ignore-scripts true` where build hooks aren't needed
+
+Everything beyond that is situational: add GuardDog when you want an offline second
+engine, OSV when you need CVE breadth across many ecosystems, zizmor + Harden-Runner
+when CI holds publish credentials.
+
+**None of these are dependencies of this skill.** The skill is markdown + bash. Its
+scripts require only baseline tooling (bash, coreutils, `curl`; `jq` only for
+`--json`) and treat every supply-chain tool above as optional — `command -v`-gated
+with graceful fallback (`preinstall-check.sh` runs without `socket`;
+`integrity-audit.sh` runs without `zizmor`, telling you it's the weaker check). You
+can adopt zero, some, or all of the tools without affecting whether the skill loads
+or its scripts run.

+ 208 - 0
skills/supply-chain-defense/scripts/exposure-check.py

@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""Match on-disk installed packages against an IOC exposure catalog.
+
+Answers the post-advisory question: "an advisory named package X@Y — do we
+have it installed right now, and where?" Cross-platform (works on Windows,
+unlike Perplexity's Bumblebee, whose exposure-catalog JSON format this borrows).
+Reads npm lockfiles and Python installed metadata; no package-manager execution,
+no network, no source reads.
+
+Usage: exposure-check.py [--catalog PATH] [--root DIR]... [--json] [--findings-only]
+
+Input:   --root dirs (default: cwd); --catalog file or dir of *.json
+         (default: bundled assets/exposure-catalog.json)
+Output:  stdout = findings (or all components), NDJSON-ish JSON with --json
+Stderr:  progress, summary, errors
+Exit:    0 no exposure, 2 usage, 3 catalog-not-found, 4 invalid-catalog,
+         10 EXPOSURE FOUND (>=1 installed package matches the catalog)
+
+Examples:
+  exposure-check.py --root ~/code
+  exposure-check.py --root . --json | jq '.data.findings[]'
+  exposure-check.py --catalog ./my-iocs.json --root /srv/app --findings-only
+"""
+import argparse, json, os, re, sys
+from pathlib import Path
+from typing import NoReturn
+
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_INVALID, EXIT_EXPOSED = 0, 2, 3, 4, 10
+SKIP_DIRS = {".git", ".hg", ".svn", "worktrees"}
+DEFAULT_CATALOG = Path(__file__).resolve().parent.parent / "assets" / "exposure-catalog.json"
+
+
+def log(msg): print(msg, file=sys.stderr)
+
+
+def die(msg, code) -> NoReturn:
+    log(f"ERROR: {msg}")
+    sys.exit(code)
+
+
+def load_catalog(path: Path):
+    files = []
+    if path.is_dir():
+        files = sorted(path.glob("*.json"))
+    elif path.is_file():
+        files = [path]
+    if not files:
+        die(f"catalog not found: {path}", EXIT_NOT_FOUND)
+    entries, ver = [], None
+    for f in files:
+        doc = {}
+        try:
+            doc = json.loads(f.read_text(encoding="utf-8"))
+        except (json.JSONDecodeError, OSError) as e:
+            die(f"invalid catalog {f}: {e}", EXIT_INVALID)
+        if ver is None:
+            ver = doc.get("schema_version")
+        elif doc.get("schema_version") != ver:
+            die(f"schema_version mismatch across catalogs: {f}", EXIT_INVALID)
+        entries.extend(doc.get("entries", []))
+    # index: (ecosystem, lowercased package name) -> {version: entry}
+    index = {}
+    for e in entries:
+        key = (e.get("ecosystem", ""), str(e.get("package", "")).lower())
+        index.setdefault(key, {})
+        for v in e.get("versions", []):
+            index[key][str(v)] = e
+    return index, ver, len(entries)
+
+
+def walk(roots):
+    for root in roots:
+        base = Path(root).expanduser()
+        if not base.exists():
+            log(f"[warn] root does not exist: {base}")
+            continue
+        for dirpath, dirnames, filenames in os.walk(base):
+            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
+            yield Path(dirpath), filenames
+
+
+def add(components, ecosystem, name, version, source):
+    if name and version:
+        components.append({"ecosystem": ecosystem, "name": str(name),
+                           "version": str(version), "source": str(source)})
+
+
+def parse_npm_lock(path: Path, components):
+    try:
+        doc = json.loads(path.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError):
+        return
+    # lockfileVersion 2/3: packages{} keyed by "node_modules/<name>"
+    for pkgpath, meta in (doc.get("packages") or {}).items():
+        if not pkgpath:
+            continue  # root package entry ""
+        name = pkgpath.split("node_modules/")[-1]
+        add(components, "npm", name, meta.get("version"), path)
+    # lockfileVersion 1: dependencies{} (recursive)
+    def walk_deps(deps):
+        for name, meta in (deps or {}).items():
+            add(components, "npm", name, meta.get("version"), path)
+            walk_deps(meta.get("dependencies"))
+    walk_deps(doc.get("dependencies"))
+
+
+REQ_RE = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*==\s*([A-Za-z0-9_.\-]+)")
+
+
+def parse_requirements(path: Path, components):
+    try:
+        for line in path.read_text(encoding="utf-8").splitlines():
+            m = REQ_RE.match(line)
+            if m:
+                add(components, "pypi", m.group(1), m.group(2), path)
+    except OSError:
+        pass
+
+
+def parse_dist_info(path: Path, components):  # *.dist-info/METADATA
+    name = ver = None
+    try:
+        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+            if line.startswith("Name:"):
+                name = line.split(":", 1)[1].strip()
+            elif line.startswith("Version:"):
+                ver = line.split(":", 1)[1].strip()
+            if name and ver:
+                break
+    except OSError:
+        return
+    add(components, "pypi", name, ver, path)
+
+
+def collect(roots):
+    components = []
+    for dirpath, filenames in walk(roots):
+        for fn in filenames:
+            full = dirpath / fn
+            if fn in ("package-lock.json", "npm-shrinkwrap.json", ".package-lock.json"):
+                parse_npm_lock(full, components)
+            elif fn.startswith("requirements") and fn.endswith(".txt"):
+                parse_requirements(full, components)
+            elif fn == "METADATA" and dirpath.name.endswith(".dist-info"):
+                parse_dist_info(full, components)
+    return components
+
+
+def main():
+    # Force UTF-8 on Windows so help text / output never crash on cp1252
+    # (the same class of bug GuardDog hits — see references/tooling-landscape.md).
+    for stream in (sys.stdout, sys.stderr):
+        try:
+            stream.reconfigure(encoding="utf-8")  # type: ignore[attr-defined]
+        except (AttributeError, ValueError):
+            pass
+    ap = argparse.ArgumentParser(add_help=True, description=__doc__,
+                                 formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("--catalog", default=str(DEFAULT_CATALOG),
+                    help="IOC catalog JSON file or dir of *.json")
+    ap.add_argument("--root", action="append", default=[],
+                    help="directory to scan (repeatable; default: cwd)")
+    ap.add_argument("--json", action="store_true", help="machine-readable output")
+    ap.add_argument("--findings-only", action="store_true",
+                    help="emit only matches, not the full component inventory")
+    args = ap.parse_args()
+
+    roots = args.root or ["."]
+    index, schema_ver, n_entries = load_catalog(Path(args.catalog).expanduser())
+    log(f"=== exposure-check: {n_entries} IOC entries (schema {schema_ver}), "
+        f"roots: {', '.join(roots)} ===")
+
+    components = collect(roots)
+    findings = []
+    for c in components:
+        bucket = index.get((c["ecosystem"], c["name"].lower()))
+        if bucket and c["version"] in bucket:
+            e = bucket[c["version"]]
+            findings.append({**c, "ioc_id": e.get("id"),
+                             "severity": e.get("severity", "unknown"),
+                             "note": e.get("note", "")})
+
+    if args.json:
+        data: dict[str, object] = {"findings": findings}
+        if not args.findings_only:
+            data["components_scanned"] = len(components)
+        print(json.dumps({"data": data, "meta": {
+            "exposed": bool(findings), "findings": len(findings),
+            "components_scanned": len(components), "ioc_entries": n_entries,
+            "schema": "axiom.tool.exposure-check.report/v1"}}))
+    else:
+        if not args.findings_only:
+            for c in components:
+                print(f"{c['ecosystem']}\t{c['name']}\t{c['version']}\t{c['source']}")
+        for f in findings:
+            log(f"  [EXPOSED] {f['ecosystem']} {f['name']}@{f['version']} "
+                f"({f['severity']}, {f['ioc_id']}) - {f['source']}")
+
+    if findings:
+        log(f"EXPOSED: {len(findings)} installed package(s) match the IOC catalog. "
+            f"Treat as incident: isolate, rotate creds, remove the package.")
+        sys.exit(EXIT_EXPOSED)
+    log(f"Clean: 0 of {len(components)} scanned components match the catalog.")
+    sys.exit(EXIT_OK)
+
+
+if __name__ == "__main__":
+    main()

+ 174 - 0
skills/supply-chain-defense/scripts/integrity-audit.sh

@@ -0,0 +1,174 @@
+#!/usr/bin/env bash
+# Self-integrity scan — detect worm persistence in Claude Code / VS Code settings.
+#
+# Flags the 2026 worm IOC: hooks / mcpServers injected into Claude Code and VS Code
+# settings, plus GitHub Actions workflows with live OIDC publish trust (the Mini
+# Shai-Hulud entry point). Read-only — it reports; you decide. Uses `zizmor` for
+# richer workflow analysis when installed.
+#
+# Usage:   integrity-audit.sh [--json] [-q] [-v] [PROJECT_DIR]
+# Input:   optional PROJECT_DIR positional (default: cwd) + $HOME config locations
+# Output:  stdout = findings (tab-separated records, or JSON with --json)
+# Stderr:  section framing, progress, verdict guidance, errors
+# Exit:    0 clean, 2 usage, 5 missing-dep (jq, with --json), 10 review-items-found
+#
+# Note: intentionally NOT `set -e` — a scanner must survive missing files and keep
+# going. Errors are handled explicitly.
+#
+# Examples:
+#   integrity-audit.sh
+#   integrity-audit.sh --json | jq '.data.review[]'
+#   integrity-audit.sh -q ./some/project    # quiet: findings only, no framing
+
+set -uo pipefail
+
+EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_REVIEW=10
+
+JSON=0; QUIET=0; VERBOSE=0; PROJECT_DIR="."
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --json)        JSON=1 ;;
+    -q|--quiet)    QUIET=1 ;;
+    -v|--verbose)  VERBOSE=1 ;;
+    -h|--help)
+      sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
+    -*) echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+    *)  PROJECT_DIR="$1" ;;
+  esac
+  shift
+done
+
+# stderr framing — colored only when stderr is a TTY and NO_COLOR unset.
+if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
+  C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_O=$'\033[0m'
+else C_Y=""; C_G=""; C_D=""; C_O=""; fi
+section() { [[ "$QUIET" -eq 1 ]] && return; printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; }
+info()    { [[ "$QUIET" -eq 1 ]] && return; printf '   %s\n' "$1" >&2; }
+vinfo()   { [[ "$VERBOSE" -eq 1 ]] && printf '   %s\n' "$1" >&2; }
+
+HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
+HAS_ZIZMOR=0; command -v zizmor >/dev/null 2>&1 && HAS_ZIZMOR=1
+if [[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]]; then
+  echo '{"error":{"code":"MISSING_DEPENDENCY","message":"jq required for --json","details":{"install":"apt-get install jq"}}}'
+  echo "ERROR: jq required for --json output" >&2
+  exit "$EXIT_MISSING_DEP"
+fi
+
+REVIEW_JSON=()    # array of compact JSON objects
+REVIEW_COUNT=0
+
+# record <category> <source> <kind> <entries-newline-separated>
+record() {
+  local category=$1 source=$2 kind=$3 entries=$4
+  REVIEW_COUNT=$((REVIEW_COUNT+1))
+  # tab-separated record to stdout (the data product, non-JSON mode)
+  if [[ "$JSON" -eq 0 ]]; then
+    local flat; flat=$(echo "$entries" | paste -sd',' - 2>/dev/null)
+    printf '%s\t%s\t%s\t%s\n' "$category" "$source" "$kind" "$flat"
+  fi
+  if [[ "$HAS_JQ" -eq 1 ]]; then
+    local obj
+    obj=$(jq -cn --arg c "$category" --arg s "$source" --arg k "$kind" \
+      --arg e "$entries" '{category:$c, source:$s, kind:$k, entries:($e|split("\n")|map(select(length>0)))}')
+    REVIEW_JSON+=("$obj")
+  fi
+  printf '   %s[review]%s %s %s: %s\n' "$C_Y" "$C_O" "$kind" "$source" \
+    "$(echo "$entries" | paste -sd',' - 2>/dev/null)" >&2
+}
+
+json_key_entries() {  # file key -> newline-separated entry list (jq)
+  local file=$1 key=$2
+  [[ -f "$file" && "$HAS_JQ" -eq 1 ]] || return 0
+  jq -r --arg k "$key" '
+    if (.[$k] // empty) == null then empty
+    elif (.[$k]|type)=="object" then (.[$k]|keys[])
+    elif (.[$k]|type)=="array"  then (.[$k][]|tostring)
+    else (.[$k]|tostring) end' "$file" 2>/dev/null
+}
+
+# ─── 1. AI-tool config: hooks / mcpServers across hosts ────────────────────
+# Broadened with the MCP host-config map from Perplexity's Bumblebee
+# (docs/inventory-sources.md) — the worm targets these persistence surfaces.
+section "AI-tool config" "hooks / mcpServers you may not have added (Claude + MCP hosts)"
+APPDATA_DIR="${APPDATA:-$HOME/AppData/Roaming}"
+CLAUDE_FILES=(
+  "$HOME/.claude/settings.json" "$HOME/.claude/settings.local.json" "$HOME/.claude.json"
+  "$HOME/.gemini/settings.json"                                    # Gemini CLI / Code Assist
+  "$HOME/Library/Application Support/Claude/claude_desktop_config.json"   # Claude Desktop (mac)
+  "$APPDATA_DIR/Claude/claude_desktop_config.json"                 # Claude Desktop (win)
+  "$HOME/.config/Claude/claude_desktop_config.json")              # Claude Desktop (linux)
+# Project-local MCP / Claude configs (skip worktrees — owned by other sessions).
+while IFS= read -r f; do CLAUDE_FILES+=("$f"); done < <(
+  find "$PROJECT_DIR" -maxdepth 4 \
+    \( -name 'settings*.json' -path '*/.claude/*' \
+       -o -name '.mcp.json' -o -name 'mcp.json' \
+       -o -name 'cline_mcp_settings.json' -o -name 'mcp_settings.json' \) \
+    -not -path '*/worktrees/*' -not -path '*/node_modules/*' 2>/dev/null)
+for f in "${CLAUDE_FILES[@]}"; do
+  [[ -f "$f" ]] || continue
+  vinfo "scanning $f"
+  for key in hooks mcpServers; do
+    entries=$(json_key_entries "$f" "$key")
+    [[ -n "$entries" ]] && record "aitool_config" "$f" "$key" "$entries"
+  done
+done
+
+# ─── 2. Editor user settings (VS Code + forks) ─────────────────────────────
+section "Editor settings" "startup / autorun / task IOCs (VS Code, Cursor, Windsurf, VSCodium)"
+EDITOR_SETTINGS=()
+for ed in Code Cursor Windsurf VSCodium; do
+  EDITOR_SETTINGS+=(
+    "$HOME/.config/$ed/User/settings.json"                       # Linux
+    "$HOME/Library/Application Support/$ed/User/settings.json"   # macOS
+    "${APPDATA:-$HOME/AppData/Roaming}/$ed/User/settings.json")  # Windows
+done
+SUSPECT='task.allowAutomaticTasks|automationProfile|shellArgs|runOnStartup|autoRun|"command":'
+for f in "${EDITOR_SETTINGS[@]}"; do
+  [[ -f "$f" ]] || continue
+  vinfo "scanning $f"
+  hits=$(grep -nEi "$SUSPECT" "$f" 2>/dev/null)
+  [[ -n "$hits" ]] && record "vscode_settings" "$f" "autorun_keys" "$hits"
+done
+info "audit extensions too: code --list-extensions --show-versions (pause <7-day, non-verified)"
+
+# ─── 3. GitHub Actions OIDC publish trust ──────────────────────────────────
+section "GitHub Actions" "live OIDC publish trust (Mini Shai-Hulud entry point)"
+WF_DIR="$PROJECT_DIR/.github/workflows"
+if [[ -d "$WF_DIR" ]]; then
+  if [[ "$HAS_ZIZMOR" -eq 1 ]]; then
+    info "running zizmor (richer workflow analysis) — see stderr"
+    [[ "$QUIET" -eq 0 ]] && zizmor "$WF_DIR" >&2 2>&1 || true
+  else
+    # Surface the degradation at info level (NOT verbose-only) — the caller must
+    # know they're getting the weaker check, or they'll assume full coverage.
+    info "NOTE: zizmor not installed — using weaker rg-based OIDC check only."
+    info "      Misses pull_request_target / template-injection. Install: uv tool install zizmor"
+  fi
+  while IFS= read -r wf; do
+    [[ -z "$wf" ]] && continue
+    pub=$(grep -nE 'npm publish|pypi|twine upload|trusted.?publish|registry-url' "$wf" 2>/dev/null)
+    record "workflow_oidc" "$wf" "id-token-write" "${pub:-id-token: write present}"
+  done < <(grep -rlE 'id-token:\s*write' "$WF_DIR" 2>/dev/null)
+else
+  info "no .github/workflows in $PROJECT_DIR"
+fi
+
+# ─── Output + verdict ──────────────────────────────────────────────────────
+if [[ "$JSON" -eq 1 ]]; then
+  printf '%s\n' "${REVIEW_JSON[@]:-}" | jq -s \
+    --argjson z "$HAS_ZIZMOR" \
+    '{data:{review: (map(select(length>0)))}, meta:{count:(map(select(length>0))|length), zizmor_used:($z==1), schema:"axiom.tool.integrity-audit.report/v1"}}'
+fi
+
+if [[ "$REVIEW_COUNT" -eq 0 ]]; then
+  [[ "$QUIET" -eq 0 ]] && printf '%sClean: nothing flagged for review.%s\n' "$C_G" "$C_O" >&2
+  exit "$EXIT_OK"
+fi
+if [[ "$QUIET" -eq 0 ]]; then
+  printf '%s%d item(s) flagged for review — confirm YOU added each.%s\n' "$C_Y" "$REVIEW_COUNT" "$C_O" >&2
+  cat >&2 <<'EOF'
+   Not proof of compromise. If any entry is unexplained, treat as an incident:
+     1. Isolate the machine.  2. Rotate every reachable credential.  3. Investigate.
+EOF
+fi
+exit "$EXIT_REVIEW"

+ 135 - 0
skills/supply-chain-defense/scripts/preinstall-check.sh

@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+# Release-age pre-check for dependencies — enforce the cooldown policy.
+#
+# Flags any package whose target version was published inside the cooldown window
+# (default 7 days), because the 2026 worm campaign poisons brand-new releases that
+# are removed within hours. Routes to `socket` for a behavioural verdict when the
+# CLI is installed. Queries public registries (npm registry / PyPI JSON API) — no
+# auth, no install, read-only.
+#
+# Usage:   preinstall-check.sh [--pip|--npm] [--json] [-q] <pkg>[@version] ...
+# Input:   one or more package specs as positionals; --pip selects the PyPI ecosystem
+# Output:  stdout = per-package records (tab-separated, or JSON with --json)
+# Stderr:  headers, socket suggestions, progress, errors
+# Exit:    0 all outside cooldown, 2 usage, 5 missing-dep, 7 registry-unavailable,
+#          10 at-least-one-inside-cooldown
+#
+# Examples:
+#   preinstall-check.sh axios react@19.0.0
+#   preinstall-check.sh --pip requests fastapi@0.110.0
+#   preinstall-check.sh --json axios | jq '.data[] | select(.inside_cooldown)'
+#   COOLDOWN_DAYS=14 preinstall-check.sh left-pad
+
+set -uo pipefail
+
+EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_UNAVAILABLE=7; EXIT_INSIDE=10
+
+ECOSYSTEM="npm"; COOLDOWN_DAYS="${COOLDOWN_DAYS:-7}"; JSON=0; QUIET=0; PKGS=()
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --pip|--pypi) ECOSYSTEM="pypi" ;;
+    --npm)        ECOSYSTEM="npm" ;;
+    --json)       JSON=1 ;;
+    -q|--quiet)   QUIET=1 ;;
+    -h|--help)    sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
+    -*)  echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+    *)   PKGS+=("$1") ;;
+  esac
+  shift
+done
+
+[[ ${#PKGS[@]} -eq 0 ]] && { echo "ERROR: no package specs given (try --help)" >&2; exit "$EXIT_USAGE"; }
+command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit "$EXIT_MISSING_DEP"; }
+HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
+[[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]] && {
+  echo '{"error":{"code":"MISSING_DEPENDENCY","message":"jq required for --json"}}'
+  echo "ERROR: jq required for --json" >&2; exit "$EXIT_MISSING_DEP"; }
+HAS_SOCKET=0; command -v socket >/dev/null 2>&1 && HAS_SOCKET=1
+
+emit() { [[ "$QUIET" -eq 1 ]] && return; printf '%s\n' "$1" >&2; }
+now_epoch=$(date +%s); inside=0; unavailable=0
+JSON_OBJS=()
+
+iso_to_epoch() {
+  local ts=$1
+  date -d "$ts" +%s 2>/dev/null && return 0
+  ts="${ts%%.*}"; ts="${ts%Z}"
+  date -j -f "%Y-%m-%dT%H:%M:%S" "$ts" +%s 2>/dev/null && return 0
+  echo ""
+}
+
+result() {  # name version published
+  local name=$1 version=$2 published=$3 days=-1 ic=false
+  if [[ -n "$version" && -n "$published" ]]; then
+    local epoch; epoch=$(iso_to_epoch "$published")
+    if [[ -n "$epoch" ]]; then
+      days=$(( (now_epoch - epoch) / 86400 ))
+      if [[ "$days" -lt "$COOLDOWN_DAYS" ]]; then ic=true; inside=1; fi
+    fi
+  fi
+  # data record → stdout (non-json mode)
+  if [[ "$JSON" -eq 0 ]]; then
+    printf '%s\t%s\t%s\t%s\t%s\n' "$ECOSYSTEM" "$name" "${version:-?}" "${days}" "$ic"
+  fi
+  [[ "$HAS_JQ" -eq 1 ]] && JSON_OBJS+=("$(jq -cn \
+    --arg e "$ECOSYSTEM" --arg n "$name" --arg v "$version" \
+    --arg p "$published" --argjson d "$days" --argjson ic "$ic" \
+    '{ecosystem:$e, name:$n, version:($v|select(length>0)), published:($p|select(length>0)), age_days:(if $d<0 then null else $d end), inside_cooldown:$ic}')")
+  # human framing → stderr
+  if [[ "$ic" == "true" ]]; then
+    emit "  [INSIDE COOLDOWN] ${name}@${version} — ${days}d ago (< ${COOLDOWN_DAYS}d). Hold off."
+  elif [[ "$days" -ge 0 ]]; then
+    emit "  [ok] ${name}@${version} — ${days}d ago (>= ${COOLDOWN_DAYS}d)."
+  else
+    emit "  [?] ${name} — version/publish time not found or registry unreachable."
+  fi
+}
+
+fetch() { curl -fsSL "$1" 2>/dev/null || { unavailable=1; echo ""; }; }
+
+check_npm() {
+  local spec=$1 name version json
+  name="${spec%@*}"; version=""
+  [[ "$spec" == *"@"* && "$spec" != @*/* ]] && version="${spec#*@}"
+  json=$(fetch "https://registry.npmjs.org/${name}")
+  [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
+  [[ -z "$version" ]] && version=$(jq -r '."dist-tags".latest // empty' <<<"$json")
+  result "$name" "$version" "$(jq -r --arg v "$version" '.time[$v] // empty' <<<"$json")"
+}
+check_pypi() {
+  local spec=$1 name version url json
+  name="${spec%==*}"; version=""
+  [[ "$spec" == *"=="* ]] && version="${spec#*==}"
+  [[ "$spec" == *"@"* ]] && { name="${spec%@*}"; version="${spec#*@}"; }
+  url="https://pypi.org/pypi/${name}/json"; [[ -n "$version" ]] && url="https://pypi.org/pypi/${name}/${version}/json"
+  json=$(fetch "$url")
+  [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
+  [[ -z "$version" ]] && version=$(jq -r '.info.version // empty' <<<"$json")
+  result "$name" "$version" "$(jq -r --arg v "$version" \
+    '(.releases[$v]//[])[0].upload_time_iso_8601 // .urls[0].upload_time_iso_8601 // empty' <<<"$json")"
+}
+
+emit "=== Pre-install check (${ECOSYSTEM}, cooldown ${COOLDOWN_DAYS}d) ==="
+for spec in "${PKGS[@]}"; do
+  case "$ECOSYSTEM" in npm) check_npm "$spec" ;; pypi) check_pypi "$spec" ;; esac
+done
+
+if [[ "$JSON" -eq 1 ]]; then
+  printf '%s\n' "${JSON_OBJS[@]:-}" | jq -s \
+    --argjson cd "$COOLDOWN_DAYS" --arg eco "$ECOSYSTEM" \
+    '{data: map(select(length>0)), meta:{ecosystem:$eco, cooldown_days:$cd, count:(map(select(length>0))|length), schema:"axiom.tool.preinstall-check.report/v1"}}'
+fi
+
+if [[ "$QUIET" -eq 0 ]]; then
+  if [[ "$HAS_SOCKET" -eq 1 ]]; then
+    emit ""; emit "Behavioural verdict:"
+    for spec in "${PKGS[@]}"; do n="${spec%@*}"; n="${n%==*}"; emit "  socket package score ${ECOSYSTEM} ${n}"; done
+  else
+    emit ""; emit "Behavioural scan (free):  npm install -g socket   # then: socket package score ${ECOSYSTEM} <pkg>"
+    emit "Or depscore MCP (no key):  claude mcp add --transport http socket-mcp https://mcp.socket.dev/"
+  fi
+fi
+
+[[ "$inside" -eq 1 ]] && exit "$EXIT_INSIDE"
+[[ "$unavailable" -eq 1 ]] && exit "$EXIT_UNAVAILABLE"
+exit "$EXIT_OK"

+ 102 - 0
skills/supply-chain-defense/tests/run.sh

@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+# Self-test for supply-chain-defense scripts + hook.
+#
+# Offline-deterministic (no network). Builds throwaway fixtures, asserts the
+# documented exit codes and key output of each script and the pre-install-scan
+# hook, then cleans up. Resolves paths relative to itself so it works both in the
+# repo and once installed to ~/.claude/skills/supply-chain-defense/.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass, 1 one or more failures
+#
+# Network-dependent checks (preinstall-check registry lookups) are intentionally
+# omitted here — run that script manually against live registries.
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+SCRIPTS="$SKILL/scripts"
+HOOK="$SKILL/../../hooks/pre-install-scan.sh"   # repo root/hooks or ~/.claude/hooks
+# Pick a python that actually executes — skips the Windows Store `python3` stub
+# (an app-execution alias that exits non-zero non-interactively).
+PYTHON=""
+for c in python python3 py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
+done
+[[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; }
+SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
+expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+
+echo "=== supply-chain-defense self-test ==="
+
+# ── exposure-check.py ──────────────────────────────────────────────────────
+echo "-- exposure-check.py --"
+"$PYTHON" "$SCRIPTS/exposure-check.py" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
+
+mkdir -p "$SB/exposed" "$SB/clean"
+printf '{"name":"a","lockfileVersion":3,"packages":{"node_modules/axios":{"version":"1.14.1"}}}' > "$SB/exposed/package-lock.json"
+printf '{"name":"b","lockfileVersion":3,"packages":{"node_modules/axios":{"version":"1.7.9"}}}'  > "$SB/clean/package-lock.json"
+
+out="$("$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/exposed" --findings-only 2>&1)"; rc=$?
+expect_exit "exposed tree -> 10" 10 "$rc"
+expect_has  "exposed tree names axios" "axios@1.14.1" "$out"
+
+"$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/clean" --findings-only >/dev/null 2>&1
+expect_exit "clean tree -> 0" 0 $?
+
+"$PYTHON" "$SCRIPTS/exposure-check.py" --catalog "$SB/nope.json" --root "$SB/clean" >/dev/null 2>&1
+expect_exit "missing catalog -> 3" 3 $?
+
+# ── integrity-audit.sh ─────────────────────────────────────────────────────
+echo "-- integrity-audit.sh --"
+bash "$SCRIPTS/integrity-audit.sh" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
+
+mkdir -p "$SB/proj/.github/workflows"
+cat > "$SB/proj/.github/workflows/x.yml" <<'YML'
+on:
+  pull_request_target:
+permissions:
+  id-token: write
+jobs: { b: { runs-on: ubuntu-latest, steps: [ { run: "npm publish" } ] } }
+YML
+out="$(bash "$SCRIPTS/integrity-audit.sh" "$SB/proj" 2>&1)"; rc=$?
+expect_exit "planted OIDC workflow -> 10" 10 "$rc"
+expect_has  "flags id-token workflow" "id-token" "$out"
+
+# ── preinstall-check.sh (offline bits only) ────────────────────────────────
+echo "-- preinstall-check.sh --"
+bash "$SCRIPTS/preinstall-check.sh" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
+bash "$SCRIPTS/preinstall-check.sh" --bogus >/dev/null 2>&1; expect_exit "bad flag -> 2" 2 $?
+bash "$SCRIPTS/preinstall-check.sh" >/dev/null 2>&1;        expect_exit "no args -> 2" 2 $?
+
+# ── pre-install-scan.sh hook (both input modes) ────────────────────────────
+echo "-- pre-install-scan.sh hook --"
+if [[ -f "$HOOK" ]]; then
+  # legacy $1 arg mode
+  out="$(bash "$HOOK" "npm install lodash" 2>&1)"; rc=$?
+  expect_exit "arg: npm install advisory -> 0" 0 "$rc"
+  expect_has  "arg: advisory text" "SUPPLY CHAIN" "$out"
+  # modern stdin-JSON mode
+  out="$(printf '{"tool_input":{"command":"pip install requests"}}' | bash "$HOOK" 2>&1)"; rc=$?
+  expect_exit "stdin: pip install advisory -> 0" 0 "$rc"
+  expect_has  "stdin: advisory text" "SUPPLY CHAIN" "$out"
+  # already-wrapped is silent
+  out="$(printf '{"tool_input":{"command":"socket npm install x"}}' | bash "$HOOK" 2>&1)"; rc=$?
+  expect_exit "stdin: socket-wrapped silent -> 0" 0 "$rc"
+  [[ -z "$out" ]] && ok "stdin: socket-wrapped produces no output" || no "stdin: socket-wrapped should be silent"
+  # hard gate
+  printf '{"tool_input":{"command":"npm install evil"}}' | SUPPLY_CHAIN_BLOCK=1 bash "$HOOK" >/dev/null 2>&1
+  expect_exit "block mode -> 2" 2 $?
+else
+  echo "  SKIP  hook not found at $HOOK"
+fi
+
+# ── summary ────────────────────────────────────────────────────────────────
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1