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 +26 -1
- package/bin/cli.js +6 -4
- package/package.json +7 -3
- package/src/format.js +3 -2
- package/src/incidents.js +28 -0
- package/src/index.js +7 -4
- package/src/iocs.json +61 -3
- package/src/scanners.js +4 -2
- package/src/supply.js +6 -2
- package/src/util.js +8 -1
- package/src/v3.js +5 -2
- package/src/v4.js +219 -0
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.
|
|
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
|
+
"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:
|
|
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
|
|
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))
|
|
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
|
-
|
|
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 };
|