secret-blast-radius 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,8 @@ Rotation order if this machine is ever compromised: 1. persistence 2. npm 3. a
34
34
 
35
35
  *(Illustrative output: fingerprints and paths are examples.)*
36
36
 
37
+ **New in 0.4:** `--harden` audits the settings that would have blunted this month's attacks and prints the exact fix: cooldowns, `--require-hashes`, uv `exclude-newer`, digest-pinned images, OIDC instead of long-lived publish/cloud keys, unpinned MCP servers, `.env` files with secrets committed to git. Also new: Python lockfiles and virtualenvs (requirements, poetry.lock, uv.lock, Pipfile.lock, pylock.toml), Docker image references, and `--incident litellm` / `--incident zeabur`.
38
+
37
39
  **New in 0.3:** `--history` shows the dates a bad version sat in your lockfile; GlassWorm editor-extension check; npm cache check; RubyGems/Cargo/Composer/Terraform/Pulumi credential files; opt-in `--osv` malicious-package lookup; a rotation plan with verify steps and provider docs.
38
40
 
39
41
  ## Why this exists
@@ -75,6 +77,8 @@ Every advisory ends the same way: *"rotate everything the machine could access."
75
77
  | `extensions` | Installed VS Code / Cursor / Windsurf / VSCodium / Kiro / remote-server extensions against 149 GlassWorm IOCs from Socket (malicious, version-scoped, and flagged sleepers), including ones pulled in through `extensionPack` |
76
78
  | `npm-cache` | Tarballs of known-bad versions in the npm cache index: proof the machine downloaded one even if no lockfile shows it today |
77
79
  | `history` (`--history`) | **Exposure window.** Walks each lockfile's git history and reports when a known-bad version entered and left (`2026-10-08 → 2026-10-10`), so you know which CI runs and machines to treat as exposed. `--since 2026-09-01` narrows it |
80
+ | `python` | Known-bad PyPI versions (litellm 1.107.0, 1.82.7/1.82.8) in requirements*.txt, poetry.lock, uv.lock, Pipfile.lock, pdm.lock, pylock.toml and project virtualenvs; the litellm `cook.pth` startup hook |
81
+ | `images` | Dockerfile `FROM` and compose `image:` references to compromised image digests/tags, and floating tags (`latest`, `main-stable`) that pointed at a compromised image during a known window |
78
82
  | `persistence` | `gh-token-monitor` (systemd unit, LaunchAgent, Windows dir, script) |
79
83
  | `host-persistence` | Crontab lines that pipe a server into `bash` (GhostAction `#ghc2`), the `manifest-cache.js --warm` process and `/tmp/tmp.ts018051808.lock` (@subql) |
80
84
  | `repo` | Workflows that contact GhostAction infrastructure, dump `git log -p --all`, or serialise `toJSON(secrets)` to the network; `.vscode/tasks.json` that auto-runs a "font"/image; Claude Code hooks that run disguised files; the tensorlake worm branch |
@@ -92,6 +96,27 @@ Every advisory ends the same way: *"rotate everything the machine could access."
92
96
 
93
97
  **Small extras:** least-privilege hints from a token's public prefix (classic `ghp_` PAT, long-lived `AKIA` key), and MCP servers started with an unpinned `npx -y <pkg>`.
94
98
 
99
+ ## `--harden`: prevent the next one
100
+
101
+ ```bash
102
+ npx secret-blast-radius --harden
103
+ ```
104
+
105
+ Read-only audit of preventive settings. Every finding has a `fix:` line with the exact change:
106
+
107
+ | Check | Why (Oct 2026 evidence) | Fix it prints |
108
+ |---|---|---|
109
+ | npm `ignore-scripts`, cooldown for npm/pnpm/Yarn/Bun | tensorlake ran from `preinstall` and was live under 2h | `ignore-scripts=true`, `min-release-age=3`, `minimumReleaseAge: 4320`, … |
110
+ | pnpm `dangerouslyAllowAllBuilds: true` | pnpm blocks dependency builds by default; this turns that off | remove it, allow-list with `allowBuilds`/`onlyBuiltDependencies` |
111
+ | requirements not pinned / no `--hash` | an unpinned `litellm` resolved to 1.107.0 | `uv pip compile --generate-hashes`, `pip install --require-hashes` |
112
+ | uv without `exclude-newer` | litellm 1.107.0 was live ~2h | `exclude-newer = "3 days"` under `[tool.uv]` |
113
+ | images not pinned by digest | litellm's `latest`/`main-stable` served a stealer for ~2.5h | `image:tag@sha256:…` + Renovate/Dependabot |
114
+ | long-lived `NPM_TOKEN`, PyPI `password:`, AWS keys in workflows | stolen long-lived tokens drove LiteLLM, tensorlake, GhostAction | npm/PyPI trusted publishing, AWS `role-to-assume` (OIDC) |
115
+ | MCP servers run with `npx -y <pkg>` | pulls whatever is latest on every start | pin `pkg@x.y.z` |
116
+ | `.env` with secret-looking values committed or not ignored (needs git) | CERT-In Oct 15: agents got in via plaintext credentials in repos | `git rm --cached`, `.gitignore`, rotate |
117
+
118
+ Checks only fire on files you have. Settings that are already safe by default (pnpm build blocking) are not nagged about. Cargo cooldowns land in Rust 1.100 (Nov 12), so they aren't checked yet.
119
+
95
120
  ## Rotation plan
96
121
 
97
122
  `--runbook` writes an ordered plan: stop the malware, then registry tokens (npm, PyPI, RubyGems, crates.io), GitHub, cloud and IaC, Kubernetes, deploy platforms, CI env, AI keys, SSH, app secrets. Each step has the exact commands, a **verify** check that the old credential is dead, a **redeploy** reminder where running processes keep old values, and links to the provider's own docs. One example of why the details matter: npm says token revocation can take up to an hour.
@@ -115,7 +140,7 @@ permissions:
115
140
  security-events: write
116
141
  steps:
117
142
  - uses: actions/checkout@v4
118
- - uses: Krrishkumar125/secret-blast-radius@v0.3.0
143
+ - uses: Krrishkumar125/secret-blast-radius@v0.4.1
119
144
  with:
120
145
  fail-on: critical
121
146
  - uses: github/codeql-action/upload-sarif@v3
package/bin/cli.js CHANGED
@@ -11,12 +11,14 @@ Read-only. Never prints secret values.
11
11
 
12
12
  Options:
13
13
  --incident <name> Tailored checklist + only the relevant checks. Names:
14
- php2shell, glassworm, railway, tensorlake, shai-hulud, subql, ghostaction, emudhra, sbi
14
+ php2shell, litellm, zeabur, glassworm, railway, tensorlake, shai-hulud, subql, ghostaction, emudhra, sbi
15
15
  --list-incidents Show incident names and status
16
16
  --json Machine-readable output
17
17
  --sarif [file] SARIF 2.1.0 for GitHub code scanning (stdout, or --sarif-file <f>)
18
18
  --ioc-file <f> Extra IOC JSON (same shape as src/iocs.json); repeatable
19
19
  --no-exec Never run helper commands (php -v, crontab -l, ps, git)
20
+ --harden Audit preventive settings (cooldowns, hashes, digest pins, OIDC,
21
+ unpinned MCP servers, .env in git) and print the exact fix
20
22
  --history Exposure window: walk lockfile git history for known-bad versions (runs git)
21
23
  --since <date> Limit --history to commits since a date (e.g. 2026-09-01)
22
24
  --osv ONLINE, opt-in: send lockfile package names+versions (nothing else) to
@@ -25,7 +27,7 @@ Options:
25
27
  --fail-on <level> Exit 1 if any finding is at/above level: critical|high|medium|low|none (default: none)
26
28
  --only <list> Comma list of scanners: persistence,npm,github,cloud,kubernetes,env,ai,ssh,dotenv,
27
29
  hardening,malicious,repo,host-persistence,php,paas,certs,install-scripts,
28
- ecosystems,extensions,npm-cache,history
30
+ ecosystems,extensions,npm-cache,history,python,images,harden
29
31
  --skip <list> Comma list of scanners to skip
30
32
  --no-env Do not inspect environment variables
31
33
  --depth <n> How deep to look for .env files in project dirs (default 4)
@@ -41,7 +43,7 @@ try {
41
43
  allowPositionals: true,
42
44
  options: {
43
45
  json: { type: 'boolean' }, sarif: { type: 'boolean' }, 'sarif-file': { type: 'string' },
44
- incident: { type: 'string' }, 'list-incidents': { type: 'boolean' }, 'ioc-file': { type: 'string', multiple: true }, 'no-exec': { type: 'boolean' }, history: { type: 'boolean' }, since: { type: 'string' }, osv: { type: 'boolean' }, runbook: { type: 'boolean' }, 'runbook-file': { type: 'string' },
46
+ incident: { type: 'string' }, 'list-incidents': { type: 'boolean' }, 'ioc-file': { type: 'string', multiple: true }, 'no-exec': { type: 'boolean' }, history: { type: 'boolean' }, harden: { type: 'boolean' }, since: { type: 'string' }, osv: { type: 'boolean' }, runbook: { type: 'boolean' }, 'runbook-file': { type: 'string' },
45
47
  'fail-on': { type: 'string', default: 'none' }, only: { type: 'string' }, skip: { type: 'string' },
46
48
  'no-env': { type: 'boolean' }, depth: { type: 'string' }, home: { type: 'string' },
47
49
  'no-color': { type: 'boolean' }, version: { type: 'boolean', short: 'v' }, help: { type: 'boolean', short: 'h' },
@@ -73,7 +75,7 @@ if (o['no-env']) skip.push('env');
73
75
  let result;
74
76
  try {
75
77
  if (o.since && !/^\d{4}-\d{2}-\d{2}/.test(o.since)) throw new Error('--since expects a date like 2026-09-01');
76
- result = await scanAsync({ osv: o.osv, history: o.history || o.since || list(o.only)?.includes('history') ? { since: o.since } : null, projects, home: o.home, only: list(o.only), skip, depth: o.depth ? Number(o.depth) : undefined, incident: o.incident, iocFiles: o['ioc-file'] ?? [], exec: o['no-exec'] ? false : undefined });
78
+ result = await scanAsync({ harden: o.harden, osv: o.osv, history: o.history || o.since || list(o.only)?.includes('history') ? { since: o.since } : null, projects, home: o.home, only: list(o.only), skip, depth: o.depth ? Number(o.depth) : undefined, incident: o.incident, iocFiles: o['ioc-file'] ?? [], exec: o['no-exec'] ? false : undefined });
77
79
  failsAt(result, o['fail-on']);
78
80
  } catch (e) {
79
81
  console.error(`secret-blast-radius: ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "secret-blast-radius",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "What would a malicious npm install steal from this machine or CI runner? Ranked inventory of reachable credentials, known-worm IOCs (Shai-Hulud, @subql, GhostAction), PHP2Shell and PaaS exposure checks, ordered rotation runbook, SARIF. Read-only, offline, never prints secrets.",
5
5
  "license": "MIT",
6
6
  "author": "Krrish Kumar (https://github.com/Krrishkumar125)",
@@ -32,7 +32,11 @@
32
32
  "osv",
33
33
  "rubygems",
34
34
  "cargo",
35
- "terraform"
35
+ "terraform",
36
+ "pypi",
37
+ "litellm",
38
+ "docker",
39
+ "hardening"
36
40
  ],
37
41
  "type": "module",
38
42
  "bin": {
@@ -52,7 +56,7 @@
52
56
  "node": ">=18.18"
53
57
  },
54
58
  "scripts": {
55
- "test": "node --test test/scanners.test.js test/supply.test.js test/cli.test.js test/v3.test.js",
59
+ "test": "node --test test/scanners.test.js test/supply.test.js test/cli.test.js test/v3.test.js test/v4.test.js",
56
60
  "check": "node --check bin/cli.js && npm test"
57
61
  }
58
62
  }
package/src/format.js CHANGED
@@ -8,7 +8,7 @@ export function formatText(result, { color = false } = {}) {
8
8
  const sev = { critical: (s) => c('1;31', s), high: (s) => c('31', s), medium: (s) => c('33', s), low: (s) => c('2', s) };
9
9
  const lines = [];
10
10
  const { counts } = result;
11
- lines.push(c('1', 'secret-blast-radius') + ' — what a malicious install script could steal from this machine right now');
11
+ lines.push(c('1', 'secret-blast-radius') + (result.harden ? ' --harden: settings that would have blunted this month\'s attacks' : ' — what a malicious install script could steal from this machine right now'));
12
12
  lines.push('');
13
13
  if (result.incident) {
14
14
  const inc = INCIDENTS[result.incident];
@@ -18,13 +18,14 @@ export function formatText(result, { color = false } = {}) {
18
18
  }
19
19
  if (result.osvQueried != null) lines.push(c('2', `OSV: checked ${result.osvQueried} package versions against MAL- advisories (online, opt-in).`), '');
20
20
  if (!result.findings.length) {
21
- lines.push(result.incident ? 'Nothing relevant found by these checks.' : 'No reachable secrets found in the places checked. Nice.');
21
+ lines.push(result.harden ? 'All checked preventive settings are in place.' : result.incident ? 'Nothing relevant found by these checks.' : 'No reachable secrets found in the places checked. Nice.');
22
22
  return lines.join('\n');
23
23
  }
24
24
  for (const f of result.findings) {
25
25
  lines.push(`${sev[f.severity](`${ICON[f.severity]} ${f.severity.toUpperCase().padEnd(8)}`)} ${f.title}`);
26
26
  lines.push(` ${c('2', f.location + (f.fingerprint ? ` ${f.fingerprint}` : '') + (f.ageDays != null ? ` (${f.ageDays}d old)` : ''))}`);
27
27
  lines.push(` ${f.detail}`);
28
+ if (f.fix) lines.push(` ${c('32', 'fix:')} ${f.fix}`);
28
29
  lines.push('');
29
30
  }
30
31
  lines.push(`Summary: ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low.`);
package/src/incidents.js CHANGED
@@ -13,6 +13,34 @@ export const INCIDENTS = {
13
13
  ],
14
14
  refs: ['https://thephp.foundation/blog/2026/10/21/todays-php-security-release-and-how-were-strengthening-php-security/', 'https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching'],
15
15
  },
16
+ litellm: {
17
+ title: 'LiteLLM 1.107.0 release compromise: PyPI wheels + Docker images (Oct 21, 2026)',
18
+ status: 'Confirmed by LiteLLM ("contained, investigation in progress"). Root cause per vendor: GitHub CLI OAuth tokens leaked via logs of an internet-exposed internal LiteLLM deployment; how that deployment was accessed is not yet confirmed.',
19
+ scanners: ['python', 'images', 'host-persistence', 'cloud', 'github', 'npm', 'ai', 'kubernetes', 'ssh', 'dotenv', 'env', 'ecosystems'],
20
+ checklist: [
21
+ 'Affected if you pip-installed litellm==1.107.0 (live 11:39–13:23 UTC Oct 21) or ran v1.107.0/latest/main-latest/main-stable images of ghcr.io/berriai/litellm{,-database,-non_root} or litellm/litellm between 11:46 and 14:20 UTC. Bad image index: sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e.',
22
+ 'Do NOT start Python or pip in an affected environment first: cook.pth and sitecustomize.py run on every interpreter start.',
23
+ 'From a shell, delete cook.pth, cook.py, sitecustomize.py and matching __pycache__/*.pyc in every affected site-packages; kill the daemon holding /tmp/remote_code_loader.lock.',
24
+ 'Rebuild venvs and images from scratch pinned to litellm==1.106.2, images pinned by digest.',
25
+ '/tmp/.pc_once_72129118c59f5636c0bb means exfiltration succeeded; its absence proves nothing.',
26
+ 'Rotate everything the host could reach: instance/task IAM role, every provider key stored in the LiteLLM proxy DB, master and virtual keys, the DATABASE_URL password, secrets in any process environment, gh/npm/PyPI tokens, SSH keys, AI-tool logins (Codex, Claude, Gemini, Cursor MCP).',
27
+ 'Check CloudTrail for sts:GetCallerIdentity then bedrock:ListFoundationModels from unknown IPs (Wiz saw 168.100.8.57, 165.227.201.8). Block egress to www.litellm.dev.',
28
+ 'Lesson: logs that store LLM response bodies can hold credentials. Do not log full response bodies; keep gh tokens in the OS keyring.',
29
+ ],
30
+ refs: ['https://docs.litellm.ai/blog/security-incident-oct-21-2026', 'https://github.com/BerriAI/litellm/issues/47826', 'https://socket.dev/blog/litellm-compromise', 'https://osv.dev/vulnerability/MAL-2026-2144'],
31
+ },
32
+ zeabur: {
33
+ title: 'Zeabur platform breach: env vars in the platform DB read (Oct 14–15, 2026)',
34
+ status: 'Confirmed by Zeabur (preliminary report). Zeabur has since announced it is shutting down.',
35
+ scanners: ['dotenv', 'env', 'github'],
36
+ checklist: [
37
+ 'Replace every password and key stored in Zeabur env vars at its provider, even if you rotated after the August incident.',
38
+ 'Recreate Zeabur access tokens and sign in again; review GitHub activity for the Zeabur GitHub App.',
39
+ 'GCP Dedicated Server users: rebuild the server (at minimum remove unknown SSH keys and change root credentials).',
40
+ 'Plan the migration off Zeabur.',
41
+ ],
42
+ refs: ['https://zeabur.com/blogs/october-2026-security-incident', 'https://zeabur.com/blogs/zeabur-is-shutting-down'],
43
+ },
16
44
  glassworm: {
17
45
  title: 'GlassWorm — malicious and sleeper Open VSX / VS Code extensions (Mar–Oct 2026)',
18
46
  status: 'Confirmed by Socket research; extensions removed from Open VSX after reports. Sleeper extensions were benign when published and are flagged as high risk, not proven malicious.',
package/src/index.js CHANGED
@@ -5,8 +5,9 @@ import { SCANNERS } from './scanners.js';
5
5
  import { SUPPLY_SCANNERS, loadIocs } from './supply.js';
6
6
  import { INCIDENTS } from './incidents.js';
7
7
  import { V3_SCANNERS, osvMalicious } from './v3.js';
8
+ import { V4_SCANNERS } from './v4.js';
8
9
 
9
- export const ALL_SCANNERS = { ...SCANNERS, ...SUPPLY_SCANNERS, ...V3_SCANNERS };
10
+ export const ALL_SCANNERS = { ...SCANNERS, ...SUPPLY_SCANNERS, ...V3_SCANNERS, ...V4_SCANNERS };
10
11
 
11
12
  function defaultExec(cmd, args) {
12
13
  try {
@@ -132,8 +133,10 @@ export function scan(options = {}) {
132
133
  exec: options.exec === false ? null : options.exec ?? defaultExec,
133
134
  checkTmp: options.checkTmp ?? options.home === undefined,
134
135
  history: options.history ?? null, // {since?: string}
136
+ harden: !!options.harden || !!options.only?.includes('harden'),
135
137
  };
136
138
  let onlyList = options.only;
139
+ if (options.harden) onlyList = ['hardening', 'harden', 'ai'];
137
140
  if (options.incident) {
138
141
  const inc = INCIDENTS[options.incident];
139
142
  if (!inc) throw new Error(`Unknown incident "${options.incident}". Use one of: ${Object.keys(INCIDENTS).join(', ')}`);
@@ -147,7 +150,7 @@ export function scan(options = {}) {
147
150
  if ((only && !only.has(name)) || skip.has(name)) continue;
148
151
  findings.push(...fn(ctx));
149
152
  }
150
- return finish(ctx, options, findings);
153
+ return finish(ctx, options, options.harden ? findings.filter((f) => f.category === 'hardening' || f.id === 'ai.mcp-unpinned') : findings);
151
154
  }
152
155
 
153
156
  /** Same as scan(), plus the opt-in OSV lookup (network) when options.osv is set. */
@@ -156,7 +159,7 @@ export async function scanAsync(options = {}) {
156
159
  if (!options.osv) return result;
157
160
  const ctx = { home: result.home, projects: result.projects, depth: options.depth ?? 4, iocs: { updated: result.iocsUpdated } };
158
161
  const { findings: osv, queried } = await osvMalicious(ctx, options.osvOptions);
159
- const known = new Set(result.findings.filter((f) => f.id === 'supply.malicious-version').map((f) => f.title.split(' ')[2] + '|' + f.location));
162
+ const known = new Set(result.findings.filter((f) => f.id === 'supply.malicious-version').map((f) => f.title.split(' ')[2].replace('==', '@') + '|' + f.location));
160
163
  const findings = osv.filter((f) => !known.has(f.title.split(' ')[2] + '|' + f.location));
161
164
  return { ...finish(ctx, options, [...result.findings, ...findings]), osvQueried: queried };
162
165
  }
@@ -164,7 +167,7 @@ export async function scanAsync(options = {}) {
164
167
  function finish(ctx, options, findings) {
165
168
  findings.sort((a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity) || (ROTATION[a.category]?.order ?? 99) - (ROTATION[b.category]?.order ?? 99));
166
169
  const counts = Object.fromEntries(SEVERITY.map((s) => [s, findings.filter((f) => f.severity === s).length]));
167
- return { scannedAt: new Date().toISOString(), home: ctx.home, projects: ctx.projects, iocsUpdated: ctx.iocs?.updated ?? options.iocsUpdated, incident: options.incident, counts, findings };
170
+ return { scannedAt: new Date().toISOString(), home: ctx.home, projects: ctx.projects, iocsUpdated: ctx.iocs?.updated ?? options.iocsUpdated, incident: options.incident, harden: !!options.harden, counts, findings };
168
171
  }
169
172
 
170
173
  export function runbook(result) {
package/src/iocs.json CHANGED
@@ -218,6 +218,29 @@
218
218
  "incident": "shai-hulud",
219
219
  "safe": "the release before the listed version (see advisory)",
220
220
  "ref": "https://www.sonatype.com/blog/mini-shai-hulud-npm-attack-more-than-2200-components-impacted"
221
+ },
222
+ {
223
+ "ecosystem": "PyPI",
224
+ "name": "litellm",
225
+ "versions": [
226
+ "1.107.0"
227
+ ],
228
+ "campaign": "litellm release compromise (Oct 21, 2026)",
229
+ "incident": "litellm",
230
+ "safe": "1.106.2",
231
+ "ref": "https://docs.litellm.ai/blog/security-incident-oct-21-2026"
232
+ },
233
+ {
234
+ "ecosystem": "PyPI",
235
+ "name": "litellm",
236
+ "versions": [
237
+ "1.82.7",
238
+ "1.82.8"
239
+ ],
240
+ "campaign": "litellm compromise (Mar 2026)",
241
+ "incident": "litellm",
242
+ "safe": "1.106.2",
243
+ "ref": "https://osv.dev/vulnerability/MAL-2026-2144"
221
244
  }
222
245
  ],
223
246
  "markers": {
@@ -228,10 +251,13 @@
228
251
  "iseekaigogo.com",
229
252
  "ci-artifacts.dev",
230
253
  "npm-cache.com",
231
- "my-github.com"
254
+ "my-github.com",
255
+ "www.litellm.dev"
232
256
  ],
233
257
  "files": [
234
- "/tmp/tmp.ts018051808.lock"
258
+ "/tmp/tmp.ts018051808.lock",
259
+ "/tmp/remote_code_loader.lock",
260
+ "/tmp/.pc_once_72129118c59f5636c0bb"
235
261
  ],
236
262
  "cron": [
237
263
  "#ghc2",
@@ -247,5 +273,37 @@
247
273
  "optionalDependencies": [
248
274
  "@opensearch/setup"
249
275
  ]
250
- }
276
+ },
277
+ "images": [
278
+ {
279
+ "incident": "litellm",
280
+ "repos": [
281
+ "ghcr.io/berriai/litellm",
282
+ "ghcr.io/berriai/litellm-database",
283
+ "ghcr.io/berriai/litellm-non_root",
284
+ "docker.io/litellm/litellm",
285
+ "litellm/litellm"
286
+ ],
287
+ "badTags": [
288
+ "v1.107.0"
289
+ ],
290
+ "floatingTags": [
291
+ "latest",
292
+ "main-latest",
293
+ "main-stable"
294
+ ],
295
+ "digests": [
296
+ "sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e",
297
+ "sha256:bd1aeee419ea36da9aa2b657288da4101887be468f020ac63d22ba6ed84a3ea5",
298
+ "sha256:56b74573eed6e4aafe4b50603dbe7853e0345f188c8e44a09f1098caf1f4bcdf"
299
+ ],
300
+ "digestPrefixes": [
301
+ "sha256:c00f9d92c427",
302
+ "sha256:7b00e91cd2fe"
303
+ ],
304
+ "window": "2026-10-21 11:46\u201314:20 UTC",
305
+ "safe": "v1.106.2 pinned by digest",
306
+ "ref": "https://docs.litellm.ai/blog/security-incident-oct-21-2026"
307
+ }
308
+ ]
251
309
  }
package/src/scanners.js CHANGED
@@ -308,7 +308,7 @@ export function scanAiConfigs(ctx) {
308
308
  try { json = JSON.parse(text); } catch { continue; }
309
309
  for (const { name, server } of mcpServersFrom(json)) {
310
310
  const pkg = unpinnedNpx(server);
311
- if (pkg) out.push({ id: 'ai.mcp-unpinned', category: 'ai', severity: 'low', title: `MCP server "${name}" runs an unpinned package (${pkg})`, location: tilde(ctx, f),
311
+ if (pkg) out.push({ id: 'ai.mcp-unpinned', category: 'ai', severity: 'low', title: `MCP server "${name}" runs an unpinned package (${pkg})`, location: tilde(ctx, f), fix: `Change the arg to "${pkg}@<exact version>" (find it with \`npm view ${pkg} version\`).`,
312
312
  detail: '`npx -y <pkg>` fetches whatever version is latest every time the agent starts, with your agent\'s environment. Pin an exact version (pkg@1.2.3) so a hijacked release is not pulled in automatically.' });
313
313
  for (const hit of literalSecretsInServer(server)) {
314
314
  out.push({
@@ -428,6 +428,7 @@ export function scanHardening(ctx) {
428
428
  id: 'hardening.ignore-scripts', category: 'hardening', severity: 'low',
429
429
  title: 'npm runs dependency install scripts', location: '~/.npmrc',
430
430
  detail: 'Every malicious package this year ran from preinstall/postinstall. Consider `ignore-scripts=true` (or pnpm, which blocks dependency builds by default) and allow-list the few that need it.',
431
+ fix: '`npm config set ignore-scripts true` (user-wide) or add `ignore-scripts=true` to the project .npmrc; run needed builds explicitly (`npm rebuild <pkg>`).',
431
432
  });
432
433
  }
433
434
  const pnpmWs = ctx.projects.map((p) => readText(join(p, 'pnpm-workspace.yaml'))).filter(Boolean).join('\n');
@@ -444,7 +445,8 @@ export function scanHardening(ctx) {
444
445
  out.push({
445
446
  id: 'hardening.release-age', category: 'hardening', severity: 'low',
446
447
  title: 'No dependency cooldown configured', location: 'package manager config',
447
- detail: `Malicious versions are usually pulled within hours. ${tips.join('; ')}. A cooldown would not have caught a compromise that went unnoticed for longer.`,
448
+ detail: 'Malicious versions are usually pulled within hours (litellm 1.107.0: under 2h; tensorlake 0.5.144: under 2h). A cooldown would not catch a compromise that goes unnoticed for longer.',
449
+ fix: tips.join('; ') + '.',
448
450
  });
449
451
  }
450
452
  return out;
package/src/supply.js CHANGED
@@ -9,11 +9,12 @@ const rel = (root, p) => relative(root, p).replace(/\\/g, '/') || basename(p);
9
9
  const tilde = (ctx, p) => (p.startsWith(ctx.home) ? '~' + p.slice(ctx.home.length).replace(/\\/g, '/') : p);
10
10
 
11
11
  export function loadIocs(extraFiles = []) {
12
- const merged = { updated: BUNDLED.updated, packages: [...BUNDLED.packages], extensions: [], markers: structuredClone(BUNDLED.markers) };
12
+ const merged = { updated: BUNDLED.updated, packages: [...BUNDLED.packages], extensions: [], images: [...(BUNDLED.images || [])], markers: structuredClone(BUNDLED.markers) };
13
13
  for (const f of extraFiles) {
14
14
  const j = JSON.parse(readFileSync(f, 'utf8'));
15
15
  merged.packages.push(...(j.packages || []));
16
16
  merged.extensions.push(...(j.extensions || []));
17
+ merged.images.push(...(j.images || []));
17
18
  for (const [k, v] of Object.entries(j.markers || {})) merged.markers[k] = [...new Set([...(merged.markers[k] || []), ...v])];
18
19
  if (j.updated && j.updated > merged.updated) merged.updated = j.updated;
19
20
  }
@@ -207,7 +208,10 @@ export function scanHostPersistence(ctx) {
207
208
  const out = [];
208
209
  const m = ctx.iocs.markers;
209
210
  for (const f of m.files || []) {
210
- if (process.platform !== 'win32' && ctx.checkTmp && exists(f)) out.push({ id: 'persistence.ioc-file', category: 'persistence', severity: 'critical', incident: 'subql', title: `Infection marker ${f} present`, location: f, detail: '@subql/common@5.8.3 leaves this lock file. Its detached stealer survives `npm uninstall`; isolate and rebuild the host.' });
211
+ if (process.platform === 'win32' || !ctx.checkTmp || !exists(f)) continue;
212
+ const ll = /remote_code_loader|\.pc_once_/.test(f);
213
+ out.push({ id: 'persistence.ioc-file', category: 'persistence', severity: 'critical', incident: ll ? 'litellm' : 'subql', title: `Infection marker ${f} present`, location: f,
214
+ detail: ll ? 'Left by the litellm 1.107.0 payload (loader lock or upload marker). The upload marker means exfiltration succeeded. Do not start Python/pip first: delete cook.pth, cook.py, sitecustomize.py from site-packages, kill the loader, rebuild, then rotate.' : '@subql/common@5.8.3 leaves this lock file. Its detached stealer survives `npm uninstall`; isolate and rebuild the host.' });
211
215
  }
212
216
  if (!ctx.exec) return out;
213
217
  const cron = ctx.exec('crontab', ['-l']);
package/src/util.js CHANGED
@@ -64,6 +64,7 @@ export function isReference(value) {
64
64
  if (/^(your|enter|insert|replace|add)[-_ ]?(your[-_ ]?)?[a-z_ -]*(here)?$/i.test(v) || /_here$/i.test(v)) return true;
65
65
  if (/^\$\{\{.*\}\}$/.test(v)) return true; // ${{ secrets.X }} (GitHub Actions)
66
66
  if (/^(x{4,}|\*{4,}|your[-_ ].*|changeme|placeholder|todo|redacted)$/i.test(v)) return true;
67
+ if (/^(fake|dummy|mock)[-_]/i.test(v) || /:\/\/[^:@\s]*:(fake|dummy|mock)[-_][^@]*@/i.test(v)) return true;
67
68
  if (/not[-_ ]?a[-_ ]?(real[-_ ]?)?secret|^change[-_ ]?me|^dummy|^placeholder|^replace[-_ ]?(me|this)|^example|^sample/i.test(v)) return true;
68
69
  return false;
69
70
  }
@@ -76,16 +77,22 @@ export function looksLikeSecretName(name) {
76
77
  return SECRET_NAME.test(name) && !NOT_SECRET_NAME.test(name);
77
78
  }
78
79
 
80
+ const EXAMPLE_WORD = /^(secret|secrets|password|passwd|pass|token|key|apikey|api|jwt|session|cookie|signing|local|localhost|dev|development|test|testing|tests|example|examples|sample|demo|dummy|fake|mock|placeholder|change|changeme|replace|me|xxx|todo|redacted|default|insecure|value|your|here|this|not|real|keyboard|cat|foo|bar|baz|my|super|unsafe|temp|tmp|ci)$/i;
79
81
  const URL_NAME = /(_url|_uri|_dsn)$|^(dsn)$/i;
80
82
 
81
83
  /** Does this name/value pair hold a literal secret? URL-style names only count when they embed credentials. */
82
84
  export function isSecretPair(name, value) {
83
85
  if (value == null || isReference(value)) return false;
84
86
  const v = String(value);
87
+ // Credentials for a database on this machine (docker-compose dev defaults) are not reachable secrets.
88
+ if (/^[a-z][a-z0-9+.-]*:\/\/[^/\s@]+@(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.internal)([:/?]|$)/i.test(v)) return false;
85
89
  if (tokenKind(v)) return true;
86
90
  // Prose like "local-secret" or "this-is-a-secret-value-..." is a committed example, not a credential.
91
+ // Word shape alone is not enough: "correct-horse-battery-staple" is a real passphrase. Only discard
92
+ // all-word values that also contain a placeholder/example word.
87
93
  const words = v.replace(/[!.]+$/, '').split(/[-_ .]+/);
88
- if (words.length >= 2 && words.every((w) => /^[a-z]+$|^[A-Z]+$|^\d+$/.test(w)) && words.some((w) => /^[a-z]{3,}$/i.test(w))) return false;
94
+ if (words.length >= 2 && words.every((w) => /^[a-z]+$|^[A-Z]+$|^\d+$/.test(w)) && words.some((w) => /^[a-z]{3,}$/i.test(w))
95
+ && words.some((w) => EXAMPLE_WORD.test(w))) return false;
89
96
  if (URL_NAME.test(name)) return /^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^@\s]+@/i.test(v) || /[?&](password|token|key|secret)=[^&\s]+/i.test(v);
90
97
  return looksLikeSecretName(name) && v.length >= 8;
91
98
  }
package/src/v3.js CHANGED
@@ -5,6 +5,7 @@ import { readFileSync, realpathSync } from 'node:fs';
5
5
  const real = (p) => { try { return realpathSync.native(p); } catch { return p; } };
6
6
  import { readText, listDir, exists, walk, isReference, fingerprint } from './util.js';
7
7
  import { parseLockfile } from './supply.js';
8
+ import { isPyLock, parsePyLock } from './v4.js';
8
9
 
9
10
  const EXT_IOCS = JSON.parse(readFileSync(new URL('./extension-iocs.json', import.meta.url), 'utf8'));
10
11
  const tilde = (ctx, p) => (p.startsWith(ctx.home) ? '~' + p.slice(ctx.home.length).replace(/\\/g, '/') : p);
@@ -200,14 +201,16 @@ export function scanHistory(ctx) {
200
201
  }
201
202
 
202
203
  /* ---------- 5. Opt-in OSV lookup for MAL- advisories (network) ---------- */
203
- const OSV_ECOSYSTEM = { npm: 'npm' };
204
+ const OSV_ECOSYSTEM = { npm: 'npm', PyPI: 'PyPI' };
204
205
  export function collectPackages(ctx) {
205
206
  const pkgs = new Map();
206
207
  for (const root of ctx.projects) {
207
208
  for (const f of walk(root, { maxDepth: ctx.depth, skip: SKIP })) {
208
- if (!LOCKFILES.has(basename(f))) continue;
209
+ const py = isPyLock(f);
210
+ if (!LOCKFILES.has(basename(f)) && !py) continue;
209
211
  const t = readText(f);
210
212
  if (!t) continue;
213
+ if (py) { for (const { name, version } of parsePyLock(f, t)) if (version && /^\d/.test(version)) pkgs.set(`PyPI:${name}@${version}`, { name, version, ecosystem: 'PyPI', file: tilde(ctx, f) }); continue; }
211
214
  for (const { name, version } of parseLockfile(f, t)) if (name && /^\d/.test(version)) pkgs.set(`${name}@${version}`, { name, version, ecosystem: 'npm', file: tilde(ctx, f) });
212
215
  }
213
216
  }
package/src/v4.js ADDED
@@ -0,0 +1,219 @@
1
+ // v0.4: Python lockfiles + installed environments, container image references, and --harden checks.
2
+ import { join, basename, relative } from 'node:path';
3
+ import { readText, listDir, exists, walk, isSecretPair } from './util.js';
4
+ import { parseDotenv } from './scanners.js';
5
+
6
+ const tilde = (ctx, p) => (p.startsWith(ctx.home) ? '~' + p.slice(ctx.home.length).replace(/\\/g, '/') : p);
7
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'vendor', '.venv', 'venv', '__pycache__', 'coverage', '.cache', '.tox', 'site-packages']);
8
+ const files = (ctx, root) => walk(root, { maxDepth: ctx.depth, skip: SKIP });
9
+
10
+ /** PEP 503 normalised name. */
11
+ export const pyName = (n) => String(n).toLowerCase().replace(/[-_.]+/g, '-');
12
+
13
+ /* ---------- Python lockfiles ---------- */
14
+ export const isPyLock = (f) => /^requirements[\w.-]*\.(txt|in)$|^constraints[\w.-]*\.txt$/i.test(basename(f)) || ['poetry.lock', 'uv.lock', 'Pipfile.lock', 'pdm.lock', 'pylock.toml'].includes(basename(f));
15
+
16
+ export function parsePyLock(file, text) {
17
+ const base = basename(file);
18
+ const out = [];
19
+ if (base === 'Pipfile.lock') {
20
+ let j; try { j = JSON.parse(text); } catch { return out; }
21
+ for (const sec of ['default', 'develop']) for (const [n, v] of Object.entries(j[sec] || {})) { const m = String(v?.version || '').match(/^==\s*(\S+)$/); if (m) out.push({ name: pyName(n), version: m[1], hashed: !!v.hashes?.length }); }
22
+ } else if (/\.(lock|toml)$/.test(base)) {
23
+ // poetry.lock / uv.lock / pdm.lock / pylock.toml: [[package]] blocks with name = "..." and version = "..."
24
+ for (const block of text.split(/^\[\[packages?\]\]\s*$/m).slice(1)) {
25
+ const n = block.match(/^name\s*=\s*"([^"]+)"/m);
26
+ const v = block.match(/^version\s*=\s*"([^"]+)"/m);
27
+ if (n && v) out.push({ name: pyName(n[1]), version: v[1], hashed: /hash\s*=\s*"sha256:|"sha256:/.test(block) });
28
+ }
29
+ } else {
30
+ // requirements.txt: join continuation lines; "name[extra]==1.2.3 ; marker --hash=..."
31
+ for (const raw of text.replace(/\\\r?\n/g, ' ').split(/\r?\n/)) {
32
+ const line = raw.replace(/(^|\s)#.*$/, '').trim();
33
+ if (!line || line.startsWith('-')) continue;
34
+ const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(\[[^\]]*\])?\s*(===?|~=|>=|<=|!=|>|<)?\s*([^\s;,]+)?/);
35
+ if (!m) continue;
36
+ out.push({ name: pyName(m[1]), version: m[3] === '==' || m[3] === '===' ? m[4] : null, spec: m[3] || null, hashed: /--hash[= ]sha256:/.test(line) });
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function sitePackages(root) {
43
+ const out = [];
44
+ for (const env of ['.venv', 'venv', 'env', '.env']) {
45
+ const base = join(root, env);
46
+ if (!exists(join(base, 'pyvenv.cfg'))) continue;
47
+ for (const py of listDir(join(base, 'lib'))) if (py.isDirectory() && /^python3/.test(py.name)) out.push(join(base, 'lib', py.name, 'site-packages'));
48
+ out.push(join(base, 'Lib', 'site-packages'));
49
+ }
50
+ return out.filter(exists);
51
+ }
52
+
53
+ export function scanPython(ctx) {
54
+ const bad = new Map();
55
+ for (const p of ctx.iocs.packages.filter((x) => x.ecosystem === 'PyPI')) { const k = pyName(p.name); bad.set(k, [...(bad.get(k) || []), p]); }
56
+ const out = [];
57
+ const seen = new Set();
58
+ const hit = (p, version, where, how) => {
59
+ const key = `${p.name}@${version}|${where}`;
60
+ if (seen.has(key)) return; seen.add(key);
61
+ out.push({ id: 'supply.malicious-version', category: 'persistence', severity: 'critical', incident: p.incident,
62
+ title: `Known-malicious package ${p.name}==${version} (${p.campaign})`, location: where,
63
+ detail: `${how}. Python runs its startup hooks on every interpreter start, including pip, so don't run Python in that environment first. Rebuild it from scratch pinned to ${p.safe}, then rotate everything the host could reach from a clean machine. ${p.ref}` });
64
+ };
65
+ const find = (name, version) => (bad.get(name) || []).find((p) => p.versions.includes(version));
66
+ for (const root of ctx.projects) {
67
+ for (const f of files(ctx, root)) {
68
+ if (!isPyLock(f)) continue;
69
+ const t = readText(f);
70
+ if (!t) continue;
71
+ for (const { name, version } of parsePyLock(f, t)) { const p = version && find(name, version); if (p) hit(p, version, tilde(ctx, f), 'Pinned in this file'); }
72
+ }
73
+ for (const sp of sitePackages(root)) {
74
+ for (const ent of listDir(sp)) {
75
+ const m = ent.name.match(/^(.+)-([^-]+)\.dist-info$/);
76
+ if (m) { const p = find(pyName(m[1]), m[2]); if (p) hit(p, m[2], tilde(ctx, join(sp, ent.name)), 'Installed in this virtualenv'); }
77
+ }
78
+ const pth = readText(join(sp, 'cook.pth'));
79
+ if (pth && /^\s*import\s+cook\b/m.test(pth)) out.push({ id: 'persistence.python-startup-hook', category: 'persistence', severity: 'critical', incident: 'litellm',
80
+ title: 'litellm 1.107.0 startup hook cook.pth is installed', location: tilde(ctx, join(sp, 'cook.pth')),
81
+ detail: 'Runs the credential stealer at every Python start. From a shell (not Python): delete cook.pth, cook.py, sitecustomize.py and their __pycache__ entries, kill the process holding /tmp/remote_code_loader.lock, rebuild the venv, rotate. https://docs.litellm.ai/blog/security-incident-oct-21-2026' });
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /* ---------- Container image references (Dockerfile FROM, compose image:) ---------- */
88
+ export function imageRefs(file, text) {
89
+ const out = [];
90
+ const base = basename(file);
91
+ const stages = new Set();
92
+ if (/^(Dockerfile|Containerfile)(\..+)?$|\.(dockerfile|containerfile)$/i.test(base)) {
93
+ for (const [i, line] of text.split(/\r?\n/).entries()) {
94
+ const m = line.match(/^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?/i);
95
+ if (!m) continue;
96
+ if (!stages.has(m[1].toLowerCase())) out.push({ ref: m[1], line: i + 1 });
97
+ if (m[2]) stages.add(m[2].toLowerCase());
98
+ }
99
+ return out;
100
+ }
101
+ if (/^(docker-)?compose(\.[\w-]+)?\.ya?ml$/i.test(base)) {
102
+ for (const [i, line] of text.split(/\r?\n/).entries()) {
103
+ const m = line.match(/^\s+image:\s*["']?([^"'\s#]+)/);
104
+ if (m) out.push({ ref: m[1], line: i + 1 });
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ export function parseImage(ref) {
111
+ const [nameTag, digest] = ref.split('@');
112
+ const slash = nameTag.lastIndexOf('/');
113
+ const colon = nameTag.lastIndexOf(':');
114
+ const hasTag = colon > slash;
115
+ let repo = (hasTag ? nameTag.slice(0, colon) : nameTag).toLowerCase();
116
+ const tag = hasTag ? nameTag.slice(colon + 1) : digest ? null : 'latest';
117
+ if (!/^[^/]+[.:][^/]*\//.test(repo) && !repo.startsWith('localhost/')) repo = 'docker.io/' + (repo.includes('/') ? repo : 'library/' + repo);
118
+ return { repo, tag, digest: digest || null, variable: /\$/.test(ref) };
119
+ }
120
+
121
+ export function scanImages(ctx) {
122
+ const rules = ctx.iocs.images || [];
123
+ const out = [];
124
+ for (const root of ctx.projects) {
125
+ for (const f of files(ctx, root)) {
126
+ if (!/docker|compose|containerfile/i.test(basename(f))) continue;
127
+ const t = readText(f);
128
+ if (!t) continue;
129
+ for (const { ref, line } of imageRefs(f, t)) {
130
+ const img = parseImage(ref);
131
+ for (const r of rules) {
132
+ const repos = r.repos.map((x) => parseImage(x + ':x').repo);
133
+ if (!repos.includes(img.repo)) continue;
134
+ const badDigest = img.digest && (r.digests.includes(img.digest) || (r.digestPrefixes || []).some((p) => img.digest.startsWith(p)));
135
+ const where = `${tilde(ctx, f)}:${line}`;
136
+ if (badDigest || (!img.digest && r.badTags.includes(img.tag))) {
137
+ out.push({ id: 'supply.malicious-image', category: 'persistence', severity: 'critical', incident: r.incident, title: `Compromised image ${ref}`, location: where,
138
+ detail: `This image ran the ${r.incident} stealer. Treat every container started from it as compromised and rotate what it could reach. Use ${r.safe}. ${r.ref}` });
139
+ } else if (!img.digest && r.floatingTags.includes(img.tag)) {
140
+ out.push({ id: 'supply.floating-image-exposed', category: 'persistence', severity: 'high', incident: r.incident, title: `${ref} pointed at the compromised image during ${r.window}`, location: where,
141
+ detail: `Any container that pulled this floating tag in that window ran the stealer. Check pull/start times (\`docker image inspect\`, k8s events). Pin ${r.safe}. ${r.ref}` });
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /* ---------- --harden: preventive settings, each with the exact fix ---------- */
151
+ const H = (title, location, detail, fix, severity = 'low', id = 'hardening.config') => ({ id, category: 'hardening', severity, title, location, detail, fix });
152
+
153
+ export function scanHarden(ctx) {
154
+ const out = [];
155
+ if (!ctx.harden) return out;
156
+ for (const root of ctx.projects) {
157
+ const all = files(ctx, root);
158
+ const rel = (f) => relative(root, f).replace(/\\/g, '/') || basename(f);
159
+ // Docker images not pinned by digest (one finding per file).
160
+ for (const f of all) {
161
+ if (!/docker|compose|containerfile/i.test(basename(f))) continue;
162
+ const t = readText(f);
163
+ if (!t) continue;
164
+ const loose = imageRefs(f, t).map((r) => ({ ...r, img: parseImage(r.ref) })).filter((r) => !r.img.digest && !r.img.variable && r.ref !== 'scratch');
165
+ if (loose.length) out.push(H(`${loose.length} image reference(s) not pinned by digest in ${rel(f)}`, tilde(ctx, f),
166
+ `Tags are mutable: LiteLLM's latest/main-stable pointed at a stealer for ~2.5 hours on Oct 21. Unpinned: ${loose.slice(0, 5).map((r) => r.ref).join(', ')}${loose.length > 5 ? ', …' : ''}.`,
167
+ 'Pin as image:tag@sha256:<digest> (get it with `docker buildx imagetools inspect <image:tag>`) and let Dependabot/Renovate bump digests.', 'low', 'hardening.image-digest'));
168
+ }
169
+ // Python: requirements without hashes / unpinned; uv without exclude-newer.
170
+ const hasUvLock = exists(join(root, 'uv.lock'));
171
+ const pyproject = readText(join(root, 'pyproject.toml')) || '';
172
+ const uvToml = readText(join(root, 'uv.toml')) || '';
173
+ for (const f of all.filter((x) => /^requirements[\w.-]*\.txt$/i.test(basename(x)))) {
174
+ const reqs = parsePyLock(f, readText(f) || '');
175
+ if (!reqs.length) continue;
176
+ const unpinned = reqs.filter((r) => !r.version);
177
+ if (unpinned.length) out.push(H(`${unpinned.length} of ${reqs.length} requirement(s) not pinned with == in ${rel(f)}`, tilde(ctx, f),
178
+ `An unpinned \`litellm\` resolved to the malicious 1.107.0 for ~2 hours on Oct 21. Unpinned: ${unpinned.slice(0, 5).map((r) => r.name).join(', ')}${unpinned.length > 5 ? ', …' : ''}.`,
179
+ 'Compile a lock: `uv pip compile requirements.in --generate-hashes -o requirements.txt` (or pip-tools `pip-compile --generate-hashes`).', 'low', 'hardening.python-pin'));
180
+ else if (!reqs.every((r) => r.hashed)) out.push(H(`${rel(f)} is pinned but has no --hash entries`, tilde(ctx, f),
181
+ 'Hashes make pip refuse any file that differs from the one you locked (pip turns on hash-checking mode when any requirement has --hash).',
182
+ 'Regenerate with `--generate-hashes` and install with `pip install --require-hashes -r requirements.txt`.', 'low', 'hardening.python-hashes'));
183
+ }
184
+ if ((hasUvLock || /\[tool\.uv\]/.test(pyproject)) && !/exclude-newer\s*=/.test(pyproject + uvToml)) out.push(H('uv has no release-age cooldown', tilde(ctx, join(root, 'pyproject.toml')),
185
+ 'uv can ignore files uploaded too recently; the malicious litellm was live for under 2 hours.',
186
+ 'Add `exclude-newer = "3 days"` under [tool.uv] in pyproject.toml (or uv.toml). With pip, where your version supports it: `pip install --uploaded-prior-to P3D`.', 'low', 'hardening.python-cooldown'));
187
+ // pnpm: builds allowed for everything
188
+ const ws = readText(join(root, 'pnpm-workspace.yaml')) || '';
189
+ if (/^\s*dangerouslyAllowAllBuilds\s*:\s*true/m.test(ws)) out.push(H('pnpm runs every dependency\'s build scripts', tilde(ctx, join(root, 'pnpm-workspace.yaml')),
190
+ 'pnpm blocks dependency build scripts by default; dangerouslyAllowAllBuilds turns that off, which is how preinstall worms like tensorlake run.',
191
+ 'Remove dangerouslyAllowAllBuilds and allow-list only what needs to build (`allowBuilds` / `onlyBuiltDependencies`, or `pnpm approve-builds`).', 'medium', 'hardening.pnpm-builds'));
192
+ // Long-lived publish/cloud secrets in workflows where OIDC exists
193
+ for (const f of all.filter((x) => /[\\/]\.github[\\/]workflows[\\/][^\\/]+\.ya?ml$/.test(x))) {
194
+ const t = readText(f) || '';
195
+ const r = rel(f);
196
+ if (/npm publish|pnpm publish|yarn npm publish/.test(t) && /NODE_AUTH_TOKEN:\s*\$\{\{\s*secrets\./.test(t)) out.push(H(`npm publish uses a long-lived token in ${r}`, tilde(ctx, f), 'A stolen NPM_TOKEN can publish from anywhere. npm trusted publishing uses short-lived OIDC credentials bound to this workflow.', 'Configure a trusted publisher on npmjs.com (package → Settings), add `permissions: id-token: write`, and drop NODE_AUTH_TOKEN. Provenance says where a package was built, not that it is safe.', 'low', 'hardening.oidc'));
197
+ if (/pypa\/gh-action-pypi-publish/.test(t) && /password:\s*\$\{\{\s*secrets\./.test(t)) out.push(H(`PyPI publish uses an API token in ${r}`, tilde(ctx, f), 'PyPI recommends Trusted Publishing over API tokens from CI.', 'Add a trusted publisher on pypi.org, set `permissions: id-token: write`, and remove `password:`.', 'low', 'hardening.oidc'));
198
+ if (/aws-actions\/configure-aws-credentials/.test(t) && /aws-secret-access-key:\s*\$\{\{\s*secrets\./.test(t)) out.push(H(`AWS access keys stored as secrets in ${r}`, tilde(ctx, f), 'Long-lived IAM keys in CI are what GhostAction-style workflows exfiltrate.', 'Use `role-to-assume` with GitHub OIDC (`permissions: id-token: write`) and delete the IAM user keys.', 'low', 'hardening.oidc'));
199
+ }
200
+ // .env files tracked by git or not ignored (needs git)
201
+ if (ctx.exec) {
202
+ const envs = all.filter((f) => /^\.env(\..+)?$/.test(basename(f)) && !/\.(example|sample|template|dist|defaults|demo|emulated|emulator|mock)$/i.test(basename(f)));
203
+ if (envs.length && ctx.exec('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'])) {
204
+ const tracked = new Set((ctx.exec('git', ['-C', root, 'ls-files', '--', ...envs.map(rel)]) || '').split(/\r?\n/).filter(Boolean));
205
+ for (const f of envs) {
206
+ const r = rel(f);
207
+ // Committed env files with only public config (NEXT_PUBLIC_*, URLs, fakes) are normal; flag only real-looking secrets.
208
+ const n = parseDotenv(readText(f) || '').filter(([k, v]) => isSecretPair(k, v)).length;
209
+ if (!n) continue;
210
+ if (tracked.has(r)) out.push(H(`${r} is committed to git with ${n} secret-looking value(s)`, tilde(ctx, f), 'Anyone with repo access (and every fork/clone) has these values, including in history.', `\`git rm --cached ${r}\`, add it to .gitignore, rotate every value in it, and scan history with gitleaks/TruffleHog.`, 'high', 'hardening.env-tracked'));
211
+ else if (ctx.exec('git', ['-C', root, 'check-ignore', '-q', r]) === null) out.push(H(`${r} holds ${n} secret-looking value(s) and is not in .gitignore`, tilde(ctx, f), 'One `git add .` away from being committed.', `Add \`${basename(f)}\` (or \`.env*\` with \`!.env.example\`) to .gitignore.`, 'medium', 'hardening.env-ignore'));
212
+ }
213
+ }
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ export const V4_SCANNERS = { python: scanPython, images: scanImages, harden: scanHarden };