secret-blast-radius 0.2.0 → 0.3.0
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 +40 -7
- package/action.yml +6 -2
- package/bin/cli.js +12 -6
- package/package.json +9 -3
- package/src/extension-iocs.json +911 -0
- package/src/format.js +5 -1
- package/src/incidents.js +14 -3
- package/src/index.js +90 -38
- package/src/scanners.js +38 -7
- package/src/supply.js +2 -1
- package/src/util.js +1 -0
- package/src/v3.js +240 -0
package/src/v3.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// v0.3 scanners: other ecosystems' credential files, editor extensions, npm cache,
|
|
2
|
+
// lockfile exposure windows from git history, and the opt-in OSV malicious lookup.
|
|
3
|
+
import { join, basename, relative } from 'node:path';
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
const real = (p) => { try { return realpathSync.native(p); } catch { return p; } };
|
|
6
|
+
import { readText, listDir, exists, walk, isReference, fingerprint } from './util.js';
|
|
7
|
+
import { parseLockfile } from './supply.js';
|
|
8
|
+
|
|
9
|
+
const EXT_IOCS = JSON.parse(readFileSync(new URL('./extension-iocs.json', import.meta.url), 'utf8'));
|
|
10
|
+
const tilde = (ctx, p) => (p.startsWith(ctx.home) ? '~' + p.slice(ctx.home.length).replace(/\\/g, '/') : p);
|
|
11
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'vendor', '.venv', 'venv', '__pycache__', 'coverage', '.cache']);
|
|
12
|
+
const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock']);
|
|
13
|
+
|
|
14
|
+
/* ---------- 1. Other ecosystems' credential files ---------- */
|
|
15
|
+
// Paths come from each tool's own docs (see README "Sources").
|
|
16
|
+
export function scanEcosystems(ctx) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const h = ctx.home;
|
|
19
|
+
const appData = join(h, 'AppData', 'Roaming');
|
|
20
|
+
const add = (f, title, value, severity, detail, category = 'npm') =>
|
|
21
|
+
out.push({ id: 'registry.plaintext-token', category, severity, title, location: tilde(ctx, f), fingerprint: value ? fingerprint(value) : undefined, detail });
|
|
22
|
+
|
|
23
|
+
// RubyGems: ~/.gem/credentials or ~/.local/share/gem/credentials (YAML ":rubygems_api_key: rubygems_...")
|
|
24
|
+
for (const f of [join(h, '.gem', 'credentials'), join(h, '.local', 'share', 'gem', 'credentials')]) {
|
|
25
|
+
const t = readText(f);
|
|
26
|
+
if (!t) continue;
|
|
27
|
+
for (const m of t.matchAll(/^\s*(:?[\w-]*api_key|https?:\/\/\S+?):\s+(\S+)\s*$/gm)) {
|
|
28
|
+
if (isReference(m[2])) continue;
|
|
29
|
+
add(f, 'Plaintext RubyGems API key', m[2], 'critical', 'Lets a stealer push gems you own. Delete it at rubygems.org → Settings → API keys, then `gem signin` with a gem-scoped key that has an expiry and MFA.');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Cargo: $CARGO_HOME/credentials.toml (cargo:token provider stores tokens in plain text)
|
|
33
|
+
const cargoHome = ctx.env.CARGO_HOME || join(h, '.cargo');
|
|
34
|
+
for (const f of [join(cargoHome, 'credentials.toml'), join(cargoHome, 'credentials')]) {
|
|
35
|
+
const t = readText(f);
|
|
36
|
+
if (!t) continue;
|
|
37
|
+
for (const m of t.matchAll(/^\s*token\s*=\s*"([^"]+)"/gm)) {
|
|
38
|
+
if (isReference(m[1])) continue;
|
|
39
|
+
add(f, 'Plaintext crates.io / Cargo registry token', m[1], 'critical', 'cargo:token stores tokens unencrypted. Revoke at crates.io/settings/tokens, then configure `global-credential-providers` (cargo:libsecret / macos-keychain / wincred) and `cargo login` again.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Composer: project auth.json and global auth.json
|
|
43
|
+
const composerHomes = [ctx.env.COMPOSER_HOME, join(h, '.config', 'composer'), join(h, '.composer'), join(appData, 'Composer')].filter(Boolean);
|
|
44
|
+
const composerFiles = [...composerHomes.map((d) => join(d, 'auth.json')), ...ctx.projects.map((p) => join(p, 'auth.json'))];
|
|
45
|
+
for (const f of new Set(composerFiles)) {
|
|
46
|
+
const t = readText(f);
|
|
47
|
+
if (!t) continue;
|
|
48
|
+
let j; try { j = JSON.parse(t); } catch { continue; }
|
|
49
|
+
const vals = [];
|
|
50
|
+
for (const kind of ['github-oauth', 'gitlab-oauth', 'gitlab-token', 'bearer', 'forgejo-token', 'http-basic', 'bitbucket-oauth']) {
|
|
51
|
+
for (const [host, v] of Object.entries(j?.[kind] || {})) {
|
|
52
|
+
const secret = typeof v === 'string' ? v : v?.password ?? v?.token ?? v?.['consumer-secret'];
|
|
53
|
+
if (typeof secret === 'string' && !isReference(secret)) vals.push([kind, host, secret]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const [kind, host, secret] of vals) {
|
|
57
|
+
const inProject = ctx.projects.some((p) => f === join(p, 'auth.json'));
|
|
58
|
+
add(f, `Composer ${kind} credential for ${host}${inProject ? ' (project auth.json)' : ''}`, secret, kind === 'github-oauth' ? 'high' : 'medium',
|
|
59
|
+
(inProject ? 'Composer docs: keep auth.json in .gitignore. ' : '') + 'Revoke the token at the host and re-add it with `composer config --global --auth`. For GitHub prefer a fine-grained, read-only, expiring token.', kind === 'github-oauth' ? 'github' : 'npm');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Terraform CLI: ~/.terraform.d/credentials.tfrc.json ({credentials: {host: {token}}})
|
|
63
|
+
for (const f of [join(h, '.terraform.d', 'credentials.tfrc.json'), join(appData, 'terraform.d', 'credentials.tfrc.json')]) {
|
|
64
|
+
const t = readText(f);
|
|
65
|
+
if (!t) continue;
|
|
66
|
+
try {
|
|
67
|
+
for (const [host, v] of Object.entries(JSON.parse(t).credentials || {})) if (v?.token && !isReference(v.token)) add(f, `Terraform token for ${host}`, v.token, 'high', 'Can run plans/applies in your HCP Terraform workspaces (and read their state and variables). `terraform logout`, revoke at the host, then `terraform login`.', 'cloud');
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
// Pulumi: ~/.pulumi/credentials.json ({accessTokens: {url: token}})
|
|
71
|
+
{
|
|
72
|
+
const f = join(ctx.env.PULUMI_HOME || join(h, '.pulumi'), 'credentials.json');
|
|
73
|
+
const t = readText(f);
|
|
74
|
+
if (t) {
|
|
75
|
+
try {
|
|
76
|
+
for (const [url, tok] of Object.entries(JSON.parse(t).accessTokens || {})) if (typeof tok === 'string' && tok && !isReference(tok)) add(f, `Pulumi access token for ${url}`, tok, 'high', 'Reads stack state and decrypts config secrets. `pulumi logout`, delete the token in Pulumi Cloud → Access tokens, then `pulumi login`.', 'cloud');
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Local Terraform state files in projects: often hold resource secrets in plain text.
|
|
81
|
+
for (const root of ctx.projects) {
|
|
82
|
+
for (const f of walk(root, { maxDepth: ctx.depth, skip: SKIP })) {
|
|
83
|
+
if (!/\.tfstate(\.backup)?$/.test(f)) continue;
|
|
84
|
+
const t = readText(f);
|
|
85
|
+
if (!t || !/"resources"\s*:\s*\[\s*\{/.test(t)) continue;
|
|
86
|
+
out.push({ id: 'cloud.tfstate', category: 'cloud', severity: 'medium', title: 'Local Terraform state file', location: tilde(ctx, f),
|
|
87
|
+
detail: 'Terraform state stores resource attributes, including passwords and keys, in plain text. Move state to an encrypted remote backend and treat secrets in it as exposed if this machine is.' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ---------- 2. Editor extensions (VS Code, Cursor, Windsurf, VSCodium, remote servers) ---------- */
|
|
94
|
+
export function extensionDirs(ctx) {
|
|
95
|
+
const h = ctx.home;
|
|
96
|
+
return [
|
|
97
|
+
['VS Code', join(h, '.vscode', 'extensions')], ['VS Code Insiders', join(h, '.vscode-insiders', 'extensions')],
|
|
98
|
+
['VS Code Server', join(h, '.vscode-server', 'extensions')], ['Cursor', join(h, '.cursor', 'extensions')],
|
|
99
|
+
['Cursor Server', join(h, '.cursor-server', 'extensions')], ['Windsurf', join(h, '.windsurf', 'extensions')],
|
|
100
|
+
['VSCodium', join(h, '.vscode-oss', 'extensions')], ['Kiro', join(h, '.kiro', 'extensions')], ['Antigravity', join(h, '.antigravity', 'extensions')],
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function scanExtensions(ctx) {
|
|
105
|
+
const iocs = new Map([...EXT_IOCS.extensions, ...(ctx.iocs.extensions || [])].map((e) => [e.id.toLowerCase(), e]));
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const [editor, dir] of extensionDirs(ctx)) {
|
|
108
|
+
for (const ent of listDir(dir)) {
|
|
109
|
+
if (!ent.isDirectory()) continue;
|
|
110
|
+
const pj = readText(join(dir, ent.name, 'package.json'));
|
|
111
|
+
if (!pj) continue;
|
|
112
|
+
let j; try { j = JSON.parse(pj); } catch { continue; }
|
|
113
|
+
if (!j.publisher || !j.name) continue;
|
|
114
|
+
const id = `${j.publisher}.${j.name}`.toLowerCase();
|
|
115
|
+
const related = [id, ...(j.extensionPack || []), ...(j.extensionDependencies || [])].map((x) => String(x).toLowerCase());
|
|
116
|
+
for (const rid of related) {
|
|
117
|
+
const ioc = iocs.get(rid);
|
|
118
|
+
if (!ioc) continue;
|
|
119
|
+
if (rid === id && ioc.versions && !ioc.versions.includes(j.version)) continue;
|
|
120
|
+
const via = rid === id ? '' : ` (pulled in by ${id} via extensionPack/extensionDependencies)`;
|
|
121
|
+
out.push({
|
|
122
|
+
id: 'editor.malicious-extension', category: 'persistence', severity: ioc.status === 'sleeper' ? 'high' : 'critical', incident: 'glassworm',
|
|
123
|
+
title: `${ioc.status === 'sleeper' ? 'Flagged sleeper' : 'Known-malicious'} ${editor} extension ${rid}${rid === id ? '@' + j.version : ''}${via}`,
|
|
124
|
+
location: tilde(ctx, join(dir, ent.name)),
|
|
125
|
+
detail: (ioc.status === 'sleeper' ? 'Published as a benign clone, then able to turn malicious via an update. ' : 'GlassWorm extensions steal GitHub/npm/Open VSX tokens and crypto wallets. ') +
|
|
126
|
+
`Uninstall it, check other extensions from the same publisher, then rotate from a clean machine. ${ioc.ref}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ---------- 3. npm cache: tarballs of known-bad versions were fetched on this machine ---------- */
|
|
135
|
+
export function scanNpmCache(ctx) {
|
|
136
|
+
const bad = ctx.iocs.packages.filter((p) => p.ecosystem === 'npm');
|
|
137
|
+
if (!bad.length) return [];
|
|
138
|
+
const needles = bad.flatMap((p) => p.versions.map((v) => ({ p, v, s: `/${p.name}/-/${p.name.split('/').pop()}-${v}.tgz` })));
|
|
139
|
+
const dirs = [ctx.env.npm_config_cache && join(ctx.env.npm_config_cache, '_cacache'), join(ctx.home, '.npm', '_cacache'), join(ctx.home, 'AppData', 'Local', 'npm-cache', '_cacache')].filter(Boolean);
|
|
140
|
+
const out = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
for (const d of new Set(dirs)) {
|
|
143
|
+
const idx = join(d, 'index-v5');
|
|
144
|
+
if (!exists(idx)) continue;
|
|
145
|
+
for (const f of walk(idx, { maxDepth: 3 })) {
|
|
146
|
+
const t = readText(f);
|
|
147
|
+
if (!t || !t.includes('/-/')) continue;
|
|
148
|
+
for (const n of needles) {
|
|
149
|
+
if (seen.has(n.s) || !t.includes(n.s)) continue;
|
|
150
|
+
seen.add(n.s);
|
|
151
|
+
out.push({ id: 'supply.npm-cache-hit', category: 'persistence', severity: 'critical', incident: n.p.incident,
|
|
152
|
+
title: `npm cache holds ${n.p.name}@${n.v} (${n.p.campaign})`, location: tilde(ctx, d),
|
|
153
|
+
detail: `This machine downloaded the malicious tarball at some point, even if no current lockfile has it. Assume its install script ran: check persistence first, then rotate from a clean device. Clear with \`npm cache clean --force\` only after you have collected evidence. ${n.p.ref}` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ---------- 4. Exposure window from lockfile git history ---------- */
|
|
161
|
+
const MAX_COMMITS = 400;
|
|
162
|
+
export function scanHistory(ctx) {
|
|
163
|
+
if (!ctx.exec || !ctx.history) return [];
|
|
164
|
+
const bad = new Map(ctx.iocs.packages.filter((p) => p.ecosystem === 'npm').map((p) => [p.name, p]));
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const root of ctx.projects) {
|
|
167
|
+
const top = ctx.exec('git', ['-C', root, 'rev-parse', '--show-toplevel']);
|
|
168
|
+
if (!top) continue;
|
|
169
|
+
const repo = real(top.trim());
|
|
170
|
+
const lockRel = walk(real(root), { maxDepth: ctx.depth, skip: SKIP }).filter((f) => LOCKFILES.has(basename(f))).map((f) => relative(repo, f).replace(/\\/g, '/'));
|
|
171
|
+
for (const lf of lockRel) {
|
|
172
|
+
const args = ['-C', repo, 'log', `--max-count=${MAX_COMMITS}`, '--format=%H %cI', ...(ctx.history.since ? [`--since=${ctx.history.since}`] : []), '--', lf];
|
|
173
|
+
const log = ctx.exec('git', args);
|
|
174
|
+
if (!log) continue;
|
|
175
|
+
const commits = log.trim().split('\n').filter(Boolean).map((l) => { const [sha, date] = l.split(' '); return { sha, date }; }).reverse(); // oldest first
|
|
176
|
+
const intervals = []; // {key, start, end}
|
|
177
|
+
const open = new Map();
|
|
178
|
+
for (const c of commits) {
|
|
179
|
+
const text = ctx.exec('git', ['-C', repo, 'show', `${c.sha}:${lf}`]);
|
|
180
|
+
const now = new Set();
|
|
181
|
+
if (text) for (const { name, version } of parseLockfile(lf, text)) if (bad.get(name)?.versions.includes(version)) now.add(`${name}@${version}`);
|
|
182
|
+
for (const k of now) if (!open.has(k)) open.set(k, { key: k, start: c, end: null });
|
|
183
|
+
for (const [k, iv] of open) if (!now.has(k)) { iv.end = c; intervals.push(iv); open.delete(k); }
|
|
184
|
+
}
|
|
185
|
+
intervals.push(...open.values());
|
|
186
|
+
for (const iv of intervals) {
|
|
187
|
+
const k = iv.key;
|
|
188
|
+
const p = bad.get(k.slice(0, k.lastIndexOf('@')));
|
|
189
|
+
const w = { first: iv.start, removed: iv.end };
|
|
190
|
+
const still = !w.removed;
|
|
191
|
+
out.push({ id: 'supply.history-exposure', category: 'persistence', severity: still ? 'critical' : 'high', incident: p.incident,
|
|
192
|
+
title: `${k} was in ${lf} ${w.first.date.slice(0, 10)} → ${still ? 'now (still present)' : w.removed.date.slice(0, 10)}`,
|
|
193
|
+
location: `${tilde(ctx, repo)} (${lf} @ ${w.first.sha.slice(0, 7)})`,
|
|
194
|
+
detail: `Any machine or CI run that installed from this lockfile in that window may have run the payload. Check CI logs/runners for those dates and rotate the secrets they held. ${p.ref}` });
|
|
195
|
+
}
|
|
196
|
+
if (commits.length >= MAX_COMMITS) out.push({ id: 'supply.history-truncated', category: 'hardening', severity: 'low', title: `Lockfile history truncated at ${MAX_COMMITS} commits`, location: `${tilde(ctx, repo)}/${lf}`, detail: 'Use --since <date> to focus on the incident window.' });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ---------- 5. Opt-in OSV lookup for MAL- advisories (network) ---------- */
|
|
203
|
+
const OSV_ECOSYSTEM = { npm: 'npm' };
|
|
204
|
+
export function collectPackages(ctx) {
|
|
205
|
+
const pkgs = new Map();
|
|
206
|
+
for (const root of ctx.projects) {
|
|
207
|
+
for (const f of walk(root, { maxDepth: ctx.depth, skip: SKIP })) {
|
|
208
|
+
if (!LOCKFILES.has(basename(f))) continue;
|
|
209
|
+
const t = readText(f);
|
|
210
|
+
if (!t) continue;
|
|
211
|
+
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
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return [...pkgs.values()];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function osvMalicious(ctx, { fetchImpl = globalThis.fetch, endpoint = 'https://api.osv.dev/v1/querybatch', batch = 500 } = {}) {
|
|
218
|
+
const pkgs = collectPackages(ctx);
|
|
219
|
+
const out = [];
|
|
220
|
+
if (!pkgs.length) return { findings: out, queried: 0 };
|
|
221
|
+
if (typeof fetchImpl !== 'function') throw new Error('--osv needs Node 18+ (global fetch)');
|
|
222
|
+
for (let i = 0; i < pkgs.length; i += batch) {
|
|
223
|
+
const chunk = pkgs.slice(i, i + batch);
|
|
224
|
+
// Only package names and versions leave the machine: never file contents or secrets.
|
|
225
|
+
const res = await fetchImpl(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ queries: chunk.map((p) => ({ package: { name: p.name, ecosystem: OSV_ECOSYSTEM[p.ecosystem] }, version: p.version })) }) });
|
|
226
|
+
if (!res.ok) throw new Error(`OSV returned HTTP ${res.status}`);
|
|
227
|
+
const j = await res.json();
|
|
228
|
+
(j.results || []).forEach((r, k) => {
|
|
229
|
+
const mal = (r?.vulns || []).filter((v) => /^MAL-/.test(v.id));
|
|
230
|
+
if (!mal.length) return;
|
|
231
|
+
const p = chunk[k];
|
|
232
|
+
out.push({ id: 'supply.osv-malicious', category: 'persistence', severity: 'critical',
|
|
233
|
+
title: `OSV lists ${p.name}@${p.version} as malicious (${mal.map((v) => v.id).join(', ')})`, location: p.file,
|
|
234
|
+
detail: `Malicious-package advisory from the OpenSSF malicious-packages feed via OSV. Treat machines that installed it as compromised. https://osv.dev/vulnerability/${mal[0].id}` });
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return { findings: out, queried: pkgs.length };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const V3_SCANNERS = { ecosystems: scanEcosystems, extensions: scanExtensions, 'npm-cache': scanNpmCache, history: scanHistory };
|