secret-blast-radius 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Krrish Kumar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,125 @@
1
- # Temporary Holding Version
1
+ # secret-blast-radius
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/Krrishkumar125/secret-blast-radius/actions/workflows/ci.yml/badge.svg)](https://github.com/Krrishkumar125/secret-blast-radius/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/secret-blast-radius)](https://www.npmjs.com/package/secret-blast-radius)
5
+
6
+ **If a malicious `npm install` ran on this machine right now, what would it get, and what would you rotate first?**
7
+
8
+ ```bash
9
+ npx secret-blast-radius
10
+ ```
11
+
12
+ ```
13
+ ✖ CRITICAL Plaintext npm credential for registry.npmjs.org
14
+ ~/.npmrc sha256:3f1c9a0b
15
+ Any install script can read this and republish every package you maintain (this is how Shai-Hulud spreads).
16
+
17
+ ✖ CRITICAL GitHub CLI token stored in plaintext
18
+ ~/.config/gh/hosts.yml sha256:a81d22e4
19
+ gh fell back to a plaintext file (no OS keyring). Install scripts can read it and push to every repo you can write to.
20
+
21
+ ▲ HIGH MCP server "github" has a literal secret in env.GITHUB_PERSONAL_ACCESS_TOKEN
22
+ ~/.cursor/mcp.json sha256:9b07e6c1
23
+
24
+ ● MEDIUM 2 secret(s) in apps/api/.env
25
+ Keys: DATABASE_URL, STRIPE_SECRET_KEY — recognised: connection string with password, Stripe live secret key.
26
+
27
+ Summary: 2 critical, 1 high, 1 medium, 0 low.
28
+ Rotation order if this machine is ever compromised: 1. npm 2. github 3. ai 4. dotenv
29
+ ```
30
+
31
+ ## Why
32
+
33
+ October 2026 alone: `tensorlake@0.5.144` and other "Mini Shai-Hulud" releases ran a `preinstall` hook that harvested
34
+ npm, GitHub, cloud, Kubernetes, SSH, `.env` **and coding-agent/MCP config** credentials, then used the npm token to
35
+ republish itself ([advisory](https://github.com/tensorlakeai/tensorlake/security/advisories/GHSA-8g63-53c9-65j2),
36
+ [StepSecurity](https://www.stepsecurity.io/blog/tensorlake-npm-compromised-hostage-token-worm)). Railway told every
37
+ customer to rotate secrets after env vars may have been decrypted
38
+ ([disclosure](https://blog.railway.com/p/security-disclosure-wednesday-october-21st)).
39
+
40
+ Advisories say "rotate everything the machine could access". Few people know what that list is. This tool writes it
41
+ for you, **before** an incident (so you can shrink it) and **during** one (so you rotate in the right order).
42
+
43
+ What's different from existing tools:
44
+
45
+ - **Not a malware/IOC scanner** ([shai-hulud-detect](https://github.com/cobenian/shai-hulud-detect) does that well) and
46
+ **not a repo secret scanner** (gitleaks, trufflehog scan git history). It inventories the credentials *reachable on the
47
+ machine or CI runner*, which is exactly what an install script can read.
48
+ - **One report across every place:** npm, GitHub CLI and git, AWS/GCP/Azure, kubeconfig and docker, SSH keys, MCP configs for
49
+ Claude, Cursor, Windsurf, VS Code, Zed, Kiro and Gemini, project `.env` files, and the environment variables a CI step inherits.
50
+ - **An ordered rotation runbook.** Order matters: the worm's `gh-token-monitor` wipes your home directory when you revoke
51
+ the stolen GitHub token, so persistence comes first, then the credentials a worm spreads with (npm, GitHub), then the rest.
52
+
53
+ ## Safety
54
+
55
+ - Read-only. It never writes anywhere except an optional runbook file you name.
56
+ - **No network requests**. Zero dependencies.
57
+ - **Secret values are never printed.** Findings show a location and a `sha256:xxxxxxxx` fingerprint so you can tell keys apart.
58
+ The test suite checks every output format for leaks.
59
+
60
+ ## Usage
61
+
62
+ ```bash
63
+ npx secret-blast-radius # scan home dir + current project
64
+ npx secret-blast-radius ~/code/api ~/code/web # scan specific projects for .env files
65
+ npx secret-blast-radius --runbook # Markdown rotation checklist to stdout
66
+ npx secret-blast-radius --runbook-file rotate.md --runbook
67
+ npx secret-blast-radius --json # machine-readable
68
+ npx secret-blast-radius --fail-on high # exit 1 if anything high or critical (CI gate)
69
+ npx secret-blast-radius --only npm,github,ai # pick scanners
70
+ ```
71
+
72
+ | Option | Meaning |
73
+ |---|---|
74
+ | `--json` | JSON report including the ordered runbook |
75
+ | `--runbook`, `--runbook-file <f>` | Markdown rotation checklist |
76
+ | `--fail-on <level>` | `critical`, `high`, `medium`, `low` or `none` (default) |
77
+ | `--only` / `--skip <list>` | `persistence,npm,github,cloud,kubernetes,env,ai,ssh,dotenv,hardening` |
78
+ | `--no-env` | Skip environment variables |
79
+ | `--depth <n>` | How deep to search project dirs for `.env*` (default 4; skips `node_modules`, `.git`, build dirs) |
80
+ | `--home <dir>` | Inspect another home directory (e.g. a mounted disk image) |
81
+
82
+ ### In CI: what does `npm install` get to see?
83
+
84
+ ```yaml
85
+ - run: npx secret-blast-radius@0 --only env,npm --fail-on critical
86
+ ```
87
+
88
+ If that fails, a secret such as `NPM_TOKEN` is exposed to every dependency's install script in that job. Move it to the
89
+ publish step only, or switch to [trusted publishing](https://docs.npmjs.com/trusted-publishers).
90
+
91
+ ## What it checks
92
+
93
+ | Scanner | Looks at | Flags |
94
+ |---|---|---|
95
+ | `persistence` | `gh-token-monitor` files (Linux, macOS, Windows) | Active Shai-Hulud "hostage token" service |
96
+ | `npm` | `~/.npmrc`, project `.npmrc` | Literal `_authToken` / `_auth` / `_password` (not `${VAR}`) |
97
+ | `github` | gh `hosts.yml`, `~/.git-credentials` | Tokens stored in plaintext instead of the keyring |
98
+ | `cloud` | `~/.aws/credentials`, gcloud ADC, Azure token cache | Long-lived keys (critical) vs session keys |
99
+ | `kubernetes` | `~/.kube/config`, `~/.docker/config.json` | Embedded tokens/client keys, registry auth without `credsStore` |
100
+ | `env` | Current process environment | Known token formats and secret-named variables |
101
+ | `ai` | MCP configs for Claude Code/Desktop, Cursor, Windsurf, VS Code, Zed, Kiro, Gemini; Codex auth | Literal keys in `env`, `headers`, `args`, URL query |
102
+ | `ssh` | `~/.ssh/*` private keys | Keys without a passphrase (PEM, PKCS#8 and OpenSSH formats) |
103
+ | `dotenv` | `.env*` in project dirs (not `.env.example`) | Secret-named keys and recognised tokens; URLs only when they embed a password |
104
+ | `hardening` | `.npmrc`, `pnpm-workspace.yaml` | No `ignore-scripts`, no dependency cooldown (`min-release-age` / `minimumReleaseAge`) |
105
+
106
+ Placeholders such as `${VAR}`, `${{ secrets.X }}`, `${env:X}`, `op://…`, `<token>`, `[YOUR_KEY]` and `your_api_key_here` are ignored.
107
+
108
+ ## Programmatic API
109
+
110
+ ```js
111
+ import { scan, runbook, failsAt, formatMarkdown } from 'secret-blast-radius';
112
+
113
+ const result = scan({ projects: ['.'], skip: ['env'] });
114
+ console.log(result.counts); // { critical, high, medium, low }
115
+ console.log(runbook(result)[0].label); // first thing to rotate
116
+ ```
117
+
118
+ ## Limits
119
+
120
+ It checks well-known locations; it can't see secrets in your OS keychain (which is the point), browser profiles, or files
121
+ in unusual paths. A clean report shrinks your blast radius; it doesn't prove a machine is uncompromised.
122
+
123
+ ## License
124
+
125
+ MIT © Krrish Kumar
package/bin/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { writeFileSync, readFileSync } from 'node:fs';
4
+ import { scan, failsAt, formatText, formatMarkdown, runbook } from '../src/index.js';
5
+
6
+ const HELP = `Usage: secret-blast-radius [options] [project-dir...]
7
+
8
+ Lists every credential an npm install script (or any process you run) could read
9
+ on this machine or CI runner, ranks it, and writes an ordered rotation runbook.
10
+ Read-only. Never prints secret values.
11
+
12
+ Options:
13
+ --json Machine-readable output
14
+ --runbook [file] Print (or write to file) a Markdown rotation runbook
15
+ --fail-on <level> Exit 1 if any finding is at/above level: critical|high|medium|low|none (default: none)
16
+ --only <list> Comma list of scanners: persistence,npm,github,cloud,kubernetes,env,ai,ssh,dotenv,hardening
17
+ --skip <list> Comma list of scanners to skip
18
+ --no-env Do not inspect environment variables
19
+ --depth <n> How deep to look for .env files in project dirs (default 4)
20
+ --home <dir> Home directory to inspect (default: your home)
21
+ --no-color Disable colours
22
+ -v, --version Print version
23
+ -h, --help Show this help
24
+ `;
25
+
26
+ let args;
27
+ try {
28
+ args = parseArgs({
29
+ allowPositionals: true,
30
+ options: {
31
+ json: { type: 'boolean' }, runbook: { type: 'boolean' }, 'runbook-file': { type: 'string' },
32
+ 'fail-on': { type: 'string', default: 'none' }, only: { type: 'string' }, skip: { type: 'string' },
33
+ 'no-env': { type: 'boolean' }, depth: { type: 'string' }, home: { type: 'string' },
34
+ 'no-color': { type: 'boolean' }, version: { type: 'boolean', short: 'v' }, help: { type: 'boolean', short: 'h' },
35
+ },
36
+ });
37
+ } catch (e) {
38
+ console.error(e.message + '\n\n' + HELP);
39
+ process.exit(2);
40
+ }
41
+ const { values: o, positionals } = args;
42
+ if (o.help) { process.stdout.write(HELP); process.exit(0); }
43
+ if (o.version) {
44
+ console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
45
+ process.exit(0);
46
+ }
47
+ // `--runbook out.md`: parseArgs treats the file as positional; detect a .md positional right after.
48
+ let runbookFile = o['runbook-file'];
49
+ let projects = positionals;
50
+ if (o.runbook && !runbookFile && positionals.length && /\.md$/i.test(positionals[0])) { runbookFile = positionals[0]; projects = positionals.slice(1); }
51
+
52
+ const list = (s) => (s ? s.split(',').map((x) => x.trim()).filter(Boolean) : undefined);
53
+ const skip = list(o.skip) ?? [];
54
+ if (o['no-env']) skip.push('env');
55
+
56
+ let result;
57
+ try {
58
+ result = scan({ projects, home: o.home, only: list(o.only), skip, depth: o.depth ? Number(o.depth) : undefined });
59
+ failsAt(result, o['fail-on']);
60
+ } catch (e) {
61
+ console.error(`secret-blast-radius: ${e.message}`);
62
+ process.exit(2);
63
+ }
64
+
65
+ if (o.json) {
66
+ process.stdout.write(JSON.stringify({ ...result, runbook: runbook(result).map(({ items, ...r }) => ({ ...r, findings: items.map((i) => i.id) })) }, null, 2) + '\n');
67
+ } else if (o.runbook) {
68
+ const md = formatMarkdown(result);
69
+ if (runbookFile) { writeFileSync(runbookFile, md); console.log(`Runbook written to ${runbookFile}`); } else process.stdout.write(md + '\n');
70
+ } else {
71
+ const color = !o['no-color'] && process.stdout.isTTY && !process.env.NO_COLOR;
72
+ console.log(formatText(result, { color }));
73
+ }
74
+ process.exit(failsAt(result, o['fail-on']) ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "name": "secret-blast-radius",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "See every credential a malicious npm install script could steal from your machine or CI runner (npm/GitHub tokens, cloud keys, MCP configs, SSH keys, .env), ranked, with an ordered rotation runbook. Read-only; never prints secrets.",
5
+ "license": "MIT",
6
+ "author": "Krrish Kumar (https://github.com/Krrishkumar125)",
7
+ "repository": { "type": "git", "url": "git+https://github.com/Krrishkumar125/secret-blast-radius.git" },
8
+ "homepage": "https://github.com/Krrishkumar125/secret-blast-radius#readme",
9
+ "bugs": "https://github.com/Krrishkumar125/secret-blast-radius/issues",
10
+ "keywords": ["security", "supply-chain", "npm", "shai-hulud", "secrets", "credentials", "rotation", "incident-response", "mcp", "ci", "devsecops"],
11
+ "type": "module",
12
+ "bin": { "secret-blast-radius": "bin/cli.js" },
13
+ "exports": { ".": "./src/index.js" },
14
+ "files": ["bin", "src", "README.md", "LICENSE"],
15
+ "engines": { "node": ">=18.18" },
16
+ "scripts": {
17
+ "test": "node --test test/scanners.test.js test/cli.test.js",
18
+ "check": "node --check bin/cli.js && npm test"
19
+ }
20
+ }
package/src/format.js ADDED
@@ -0,0 +1,40 @@
1
+ import { runbook } from './index.js';
2
+
3
+ const ICON = { critical: '✖', high: '▲', medium: '●', low: '·' };
4
+
5
+ export function formatText(result, { color = false } = {}) {
6
+ const c = (code, s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
7
+ const sev = { critical: (s) => c('1;31', s), high: (s) => c('31', s), medium: (s) => c('33', s), low: (s) => c('2', s) };
8
+ const lines = [];
9
+ const { counts } = result;
10
+ lines.push(c('1', 'secret-blast-radius') + ' — what a malicious install script could steal from this machine right now');
11
+ lines.push('');
12
+ if (!result.findings.length) {
13
+ lines.push('No reachable secrets found in the places checked. Nice.');
14
+ return lines.join('\n');
15
+ }
16
+ for (const f of result.findings) {
17
+ lines.push(`${sev[f.severity](`${ICON[f.severity]} ${f.severity.toUpperCase().padEnd(8)}`)} ${f.title}`);
18
+ lines.push(` ${c('2', f.location + (f.fingerprint ? ` ${f.fingerprint}` : '') + (f.ageDays != null ? ` (${f.ageDays}d old)` : ''))}`);
19
+ lines.push(` ${f.detail}`);
20
+ lines.push('');
21
+ }
22
+ lines.push(`Summary: ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low.`);
23
+ const rb = runbook(result);
24
+ if (rb.length) lines.push(`Rotation order if this machine is ever compromised: ${rb.map((r, i) => `${i + 1}. ${r.category}`).join(' ')}`);
25
+ lines.push('Run with --runbook for step-by-step rotation, --json for machine output. Secret values are never printed.');
26
+ return lines.join('\n');
27
+ }
28
+
29
+ export function formatMarkdown(result) {
30
+ const out = ['# Secret rotation runbook', '', `Generated ${result.scannedAt} by secret-blast-radius. Values are never included; fingerprints let you match items.`, ''];
31
+ const rb = runbook(result);
32
+ if (!rb.length) return out.concat('Nothing to rotate: no reachable secrets were found.').join('\n');
33
+ out.push('Work top to bottom. The order matters: stop persistence first, then the credentials a worm uses to spread (npm, GitHub), then everything else.', '');
34
+ rb.forEach((step, i) => {
35
+ out.push(`## ${i + 1}. ${step.label}`, '');
36
+ for (const it of step.items) out.push(`- [ ] **${it.title}** — \`${it.location}\`${it.fingerprint ? ` (${it.fingerprint})` : ''}`);
37
+ out.push('', ...step.steps.map((s) => `1. ${s}`), '', `Reference: ${step.url}`, '');
38
+ });
39
+ return out.join('\n');
40
+ }
package/src/index.js ADDED
@@ -0,0 +1,91 @@
1
+ import { homedir } from 'node:os';
2
+ import { resolve } from 'node:path';
3
+ import { SCANNERS } from './scanners.js';
4
+
5
+ export const SEVERITY = ['critical', 'high', 'medium', 'low'];
6
+
7
+ /** Order in which to rotate after a suspected compromise. Lower = earlier. */
8
+ export const ROTATION = {
9
+ persistence: {
10
+ order: 0, label: 'Stop the worm first',
11
+ steps: [
12
+ 'Disconnect the machine from the network.',
13
+ 'Remove gh-token-monitor BEFORE revoking any GitHub token (revoking first triggers `rm -rf ~`). Linux: `systemctl --user disable --now gh-token-monitor.service`; macOS: `launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.gh-token-monitor.plist`; Windows: delete the gh-token-monitor scheduled task.',
14
+ 'Do every rotation below from a different, clean machine, then rebuild this one.',
15
+ ],
16
+ url: 'https://github.com/tensorlakeai/tensorlake/security/advisories/GHSA-8g63-53c9-65j2',
17
+ },
18
+ npm: {
19
+ order: 1, label: 'npm publish tokens (stops the worm republishing your packages)',
20
+ steps: ['Revoke every token: `npm token list` then `npm token revoke <id>`, or npmjs.com → Access Tokens.', 'Check your packages for versions you did not publish: `npm view <pkg> time`.', 'Move publishing to trusted publishing (OIDC) or granular tokens with expiry.'],
21
+ url: 'https://docs.npmjs.com/revoking-access-tokens',
22
+ },
23
+ github: {
24
+ order: 2, label: 'GitHub tokens and sessions',
25
+ steps: ['`gh auth logout` then `gh auth login` (stores the new token in the OS keyring).', 'Revoke old PATs and OAuth apps: github.com/settings/tokens and /settings/applications.', 'Look for commits you did not make (e.g. author "claude", message "chore: update dependencies") and unexpected workflows or branches.'],
26
+ url: 'https://github.com/settings/tokens',
27
+ },
28
+ cloud: {
29
+ order: 3, label: 'Cloud credentials',
30
+ steps: ['AWS: deactivate then delete the access key in IAM, create a new one, or better, switch to `aws sso login`.', 'GCP: `gcloud auth application-default revoke`; delete service-account keys in IAM.', 'Azure: `az logout`, revoke sessions in Entra ID.', 'Review CloudTrail / audit logs for activity from unknown IPs.'],
31
+ url: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#rotating_access_keys_console',
32
+ },
33
+ kubernetes: {
34
+ order: 4, label: 'Kubernetes and container registries',
35
+ steps: ['Rotate service-account tokens / client certs referenced by your kubeconfig.', '`docker logout <registry>` and revoke the registry token in its UI; then set "credsStore".'],
36
+ url: 'https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/',
37
+ },
38
+ env: {
39
+ order: 5, label: 'Secrets present in the environment / CI',
40
+ steps: ['Rotate each listed variable at its provider and update the CI secret store.', 'In CI, pass secrets only to the steps that need them, never to `npm install`.'],
41
+ url: 'https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions',
42
+ },
43
+ ai: {
44
+ order: 6, label: 'AI provider keys and MCP servers',
45
+ steps: ['Revoke and reissue the keys in each provider console (OpenAI, Anthropic, etc.).', 'Replace literal values in MCP configs with "${ENV_VAR}" references.'],
46
+ url: 'https://blog.trailofbits.com/2025/04/30/insecure-credential-storage-plagues-mcp/',
47
+ },
48
+ ssh: {
49
+ order: 7, label: 'SSH keys',
50
+ steps: ['Generate a new key (`ssh-keygen -t ed25519`) with a passphrase.', 'Replace the public key on GitHub/GitLab and in every server\'s authorized_keys; remove the old one.'],
51
+ url: 'https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent',
52
+ },
53
+ dotenv: {
54
+ order: 8, label: 'Application secrets in .env files',
55
+ steps: ['Rotate database passwords, payment keys and webhook secrets at each provider.', 'Redeploy: most platforms only read env vars at process start.'],
56
+ url: 'https://12factor.net/config',
57
+ },
58
+ };
59
+
60
+ export function scan(options = {}) {
61
+ const ctx = {
62
+ home: resolve(options.home ?? homedir()),
63
+ projects: (options.projects?.length ? options.projects : [process.cwd()]).map((p) => resolve(p)),
64
+ env: options.env ?? process.env,
65
+ depth: options.depth ?? 4,
66
+ };
67
+ const only = options.only ? new Set(options.only) : null;
68
+ const skip = new Set(options.skip ?? []);
69
+ const findings = [];
70
+ for (const [name, fn] of Object.entries(SCANNERS)) {
71
+ if ((only && !only.has(name)) || skip.has(name)) continue;
72
+ findings.push(...fn(ctx));
73
+ }
74
+ findings.sort((a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity) || (ROTATION[a.category]?.order ?? 99) - (ROTATION[b.category]?.order ?? 99));
75
+ const counts = Object.fromEntries(SEVERITY.map((s) => [s, findings.filter((f) => f.severity === s).length]));
76
+ return { scannedAt: new Date().toISOString(), home: ctx.home, projects: ctx.projects, counts, findings };
77
+ }
78
+
79
+ export function runbook(result) {
80
+ const cats = [...new Set(result.findings.map((f) => f.category))].filter((c) => ROTATION[c]).sort((a, b) => ROTATION[a].order - ROTATION[b].order);
81
+ return cats.map((c) => ({ category: c, ...ROTATION[c], items: result.findings.filter((f) => f.category === c) }));
82
+ }
83
+
84
+ export function failsAt(result, level) {
85
+ if (!level || level === 'none') return false;
86
+ const idx = SEVERITY.indexOf(level);
87
+ if (idx < 0) throw new Error(`Unknown severity "${level}". Use one of: ${SEVERITY.join(', ')}, none`);
88
+ return result.findings.some((f) => SEVERITY.indexOf(f.severity) <= idx);
89
+ }
90
+
91
+ export { formatText, formatMarkdown } from './format.js';
@@ -0,0 +1,418 @@
1
+ import { join, relative } from 'node:path';
2
+ import {
3
+ fingerprint, readText, readBytes, listDir, exists, mtime,
4
+ isReference, looksLikeSecretName, isSecretPair, tokenKind, walk,
5
+ } from './util.js';
6
+
7
+ const days = (d) => (d ? Math.floor((Date.now() - d.getTime()) / 86400000) : null);
8
+ const tilde = (ctx, p) => (p.startsWith(ctx.home) ? '~' + p.slice(ctx.home.length).replace(/\\/g, '/') : p);
9
+
10
+ /* ---------------- 0. Active worm persistence (Shai-Hulud family) ---------------- */
11
+ export function scanPersistence(ctx) {
12
+ const h = ctx.home;
13
+ const iocs = [
14
+ join(h, '.config', 'gh-token-monitor'),
15
+ join(h, '.local', 'bin', 'gh-token-monitor.sh'),
16
+ join(h, '.config', 'systemd', 'user', 'gh-token-monitor.service'),
17
+ join(h, 'Library', 'LaunchAgents', 'com.user.gh-token-monitor.plist'),
18
+ join(h, 'AppData', 'Local', 'gh-token-monitor'),
19
+ ];
20
+ return iocs.filter(exists).map((p) => ({
21
+ id: 'persistence.gh-token-monitor',
22
+ category: 'persistence',
23
+ severity: 'critical',
24
+ title: 'Worm persistence found: gh-token-monitor',
25
+ location: tilde(ctx, p),
26
+ detail:
27
+ 'This is the "hostage token" service planted by the Mini Shai-Hulud npm worm (e.g. tensorlake@0.5.144). ' +
28
+ 'It wipes your home directory if the stolen GitHub token is revoked. Remove it BEFORE revoking any GitHub token.',
29
+ }));
30
+ }
31
+
32
+ /* ---------------- 1. npm ---------------- */
33
+ export function scanNpmrc(ctx) {
34
+ const files = [join(ctx.home, '.npmrc'), ...ctx.projects.map((p) => join(p, '.npmrc'))];
35
+ const out = [];
36
+ for (const f of new Set(files)) {
37
+ const text = readText(f);
38
+ if (!text) continue;
39
+ for (const line of text.split(/\r?\n/)) {
40
+ const m = line.match(/^\s*([^#;=\s]*(?:_authToken|_auth|_password))\s*=\s*(.+?)\s*$/);
41
+ if (!m) continue;
42
+ const [, key, value] = m;
43
+ if (isReference(value)) continue;
44
+ const registry = key.replace(/:?_(authToken|auth|password)$/, '').replace(/^\/\//, '') || 'registry.npmjs.org (default)';
45
+ const publishable = /registry\.npmjs\.org|^registry\.npmjs\.org \(default\)$/.test(registry);
46
+ out.push({
47
+ id: 'npm.plaintext-token',
48
+ category: 'npm',
49
+ severity: publishable ? 'critical' : 'high',
50
+ title: `Plaintext npm credential for ${registry}`,
51
+ location: tilde(ctx, f),
52
+ fingerprint: fingerprint(value),
53
+ detail:
54
+ (publishable
55
+ ? 'Any install script can read this and republish every package you maintain (this is how Shai-Hulud spreads). '
56
+ : 'Any install script can read this registry credential. ') +
57
+ 'Prefer short-lived tokens or npm trusted publishing (OIDC) from CI.',
58
+ });
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /* ---------------- 2. GitHub ---------------- */
65
+ export function scanGitHub(ctx) {
66
+ const out = [];
67
+ const hosts = [join(ctx.home, '.config', 'gh', 'hosts.yml'), join(ctx.home, 'AppData', 'Roaming', 'GitHub CLI', 'hosts.yml')];
68
+ for (const f of hosts) {
69
+ const text = readText(f);
70
+ if (!text) continue;
71
+ const seen = new Set();
72
+ for (const m of text.matchAll(/oauth_token:\s*(\S+)/g)) {
73
+ if (isReference(m[1]) || seen.has(m[1])) continue;
74
+ seen.add(m[1]);
75
+ out.push({
76
+ id: 'github.gh-cli-plaintext',
77
+ category: 'github',
78
+ severity: 'critical',
79
+ title: 'GitHub CLI token stored in plaintext',
80
+ location: tilde(ctx, f),
81
+ fingerprint: fingerprint(m[1]),
82
+ detail: 'gh fell back to a plaintext file (no OS keyring). Install scripts can read it and push to every repo you can write to.',
83
+ });
84
+ }
85
+ }
86
+ const gitCreds = join(ctx.home, '.git-credentials');
87
+ const gc = readText(gitCreds);
88
+ if (gc) {
89
+ for (const line of gc.split(/\r?\n/)) {
90
+ const m = line.match(/^https?:\/\/([^:]+):([^@]+)@([^/\s]+)/);
91
+ if (!m) continue;
92
+ out.push({
93
+ id: 'github.git-credentials',
94
+ category: 'github',
95
+ severity: 'high',
96
+ title: `Plaintext git credential for ${m[3]}`,
97
+ location: tilde(ctx, gitCreds),
98
+ fingerprint: fingerprint(m[2]),
99
+ detail: 'git credential.helper=store keeps passwords/tokens unencrypted. Switch to the OS credential manager.',
100
+ });
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /* ---------------- 3. Cloud ---------------- */
107
+ export function scanCloud(ctx) {
108
+ const out = [];
109
+ const awsF = join(ctx.home, '.aws', 'credentials');
110
+ const aws = readText(awsF);
111
+ if (aws) {
112
+ let profile = 'default';
113
+ for (const line of aws.split(/\r?\n/)) {
114
+ const p = line.match(/^\s*\[([^\]]+)\]/);
115
+ if (p) profile = p[1];
116
+ const k = line.match(/^\s*aws_access_key_id\s*=\s*(\S+)/);
117
+ if (k && !isReference(k[1])) {
118
+ const permanent = k[1].startsWith('AKIA');
119
+ out.push({
120
+ id: 'cloud.aws-static-key',
121
+ category: 'cloud',
122
+ severity: permanent ? 'critical' : 'medium',
123
+ title: `AWS ${permanent ? 'long-lived' : 'session'} access key (profile "${profile}")`,
124
+ location: tilde(ctx, awsF),
125
+ fingerprint: fingerprint(k[1]),
126
+ ageDays: days(mtime(awsF)),
127
+ detail: permanent
128
+ ? 'Long-lived IAM user keys never expire. Prefer `aws sso login` / IAM Identity Center so a stolen file is useless within hours.'
129
+ : 'Temporary credentials: lower risk, but still usable until they expire.',
130
+ });
131
+ }
132
+ }
133
+ }
134
+ const gcpF = join(ctx.home, '.config', 'gcloud', 'application_default_credentials.json');
135
+ const gcp = readText(gcpF);
136
+ if (gcp) {
137
+ let type = 'unknown';
138
+ try { type = JSON.parse(gcp).type || type; } catch {}
139
+ out.push({
140
+ id: 'cloud.gcp-adc',
141
+ category: 'cloud',
142
+ severity: type === 'service_account' ? 'critical' : 'high',
143
+ title: `Google Cloud application default credentials (${type})`,
144
+ location: tilde(ctx, gcpF),
145
+ detail: type === 'service_account'
146
+ ? 'A service-account key file never expires. Delete it and use workload identity or impersonation.'
147
+ : 'A refresh token: valid until revoked with `gcloud auth application-default revoke`.',
148
+ });
149
+ }
150
+ const azF = join(ctx.home, '.azure', 'msal_token_cache.json');
151
+ if (exists(azF)) {
152
+ out.push({
153
+ id: 'cloud.azure-token-cache', category: 'cloud', severity: 'high',
154
+ title: 'Azure CLI token cache', location: tilde(ctx, azF),
155
+ detail: 'Refresh tokens for your Azure tenant(s). Revoke sessions with `az logout` and in Entra ID if exposed.',
156
+ });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /* ---------------- 4. Kubernetes & container registries ---------------- */
162
+ export function scanKubeDocker(ctx) {
163
+ const out = [];
164
+ const kubeF = join(ctx.home, '.kube', 'config');
165
+ const kube = readText(kubeF);
166
+ if (kube) {
167
+ const embedded = (kube.match(/^\s*(token|client-key-data|password):\s*\S+/gm) || []).length;
168
+ if (embedded) {
169
+ out.push({
170
+ id: 'k8s.embedded-credentials', category: 'kubernetes', severity: 'high',
171
+ title: `kubeconfig with ${embedded} embedded credential(s)`, location: tilde(ctx, kubeF),
172
+ detail: 'Static tokens or client keys give cluster access to anyone who copies this file. Prefer exec-based auth plugins (short-lived).',
173
+ });
174
+ }
175
+ }
176
+ const dockerF = join(ctx.home, '.docker', 'config.json');
177
+ const docker = readText(dockerF);
178
+ if (docker) {
179
+ try {
180
+ const cfg = JSON.parse(docker);
181
+ for (const [registry, v] of Object.entries(cfg.auths || {})) {
182
+ if (v && (v.auth || v.identitytoken)) {
183
+ out.push({
184
+ id: 'registry.docker-plaintext', category: 'kubernetes', severity: 'high',
185
+ title: `Container registry credential for ${registry} (base64, not encrypted)`,
186
+ location: tilde(ctx, dockerF), fingerprint: fingerprint(v.auth || v.identitytoken),
187
+ detail: 'Set "credsStore" (osxkeychain / wincred / pass / secretservice) so docker keeps this out of the file.',
188
+ });
189
+ }
190
+ }
191
+ } catch {}
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /* ---------------- 5. Environment variables (what a CI install step sees) ---------------- */
197
+ export function scanEnv(ctx) {
198
+ const out = [];
199
+ for (const [name, value] of Object.entries(ctx.env)) {
200
+ if (!value || isReference(value)) continue;
201
+ const kind = tokenKind(value);
202
+ if (!isSecretPair(name, value)) continue;
203
+ out.push({
204
+ id: 'env.secret', category: 'env', severity: kind && /npm|GitHub|AWS/.test(kind) ? 'critical' : 'high',
205
+ title: `Secret in environment: ${name}${kind ? ` (${kind})` : ''}`,
206
+ location: 'process environment', fingerprint: fingerprint(value),
207
+ detail: 'Every install script and child process inherits this. In CI, scope secrets to the steps that need them, not the install step.',
208
+ });
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /* ---------------- 6. AI tools & MCP configs ---------------- */
214
+ function mcpServersFrom(obj, path = []) {
215
+ // Collect {name, env, headers, args} objects found under any "mcpServers" / "servers" / "mcp_servers" key.
216
+ const found = [];
217
+ if (!obj || typeof obj !== 'object') return found;
218
+ for (const [k, v] of Object.entries(obj)) {
219
+ if (/^(mcpServers|mcp_servers|servers)$/.test(k) && v && typeof v === 'object' && !Array.isArray(v)) {
220
+ for (const [name, s] of Object.entries(v)) if (s && typeof s === 'object') found.push({ name, server: s, path: [...path, k, name] });
221
+ } else if (v && typeof v === 'object') {
222
+ found.push(...mcpServersFrom(v, [...path, k]));
223
+ }
224
+ }
225
+ return found;
226
+ }
227
+
228
+ function literalSecretsInServer(server) {
229
+ const hits = [];
230
+ for (const bag of ['env', 'headers', 'http_headers']) {
231
+ for (const [k, v] of Object.entries(server[bag] || {})) {
232
+ if (typeof v !== 'string' || isReference(v)) continue;
233
+ const val = v.replace(/^Bearer\s+/i, '');
234
+ if (isReference(val)) continue;
235
+ if (isSecretPair(k, val) || /^authorization$/i.test(k)) hits.push({ key: `${bag}.${k}`, value: val });
236
+ }
237
+ }
238
+ for (const a of server.args || []) {
239
+ if (typeof a !== 'string') continue;
240
+ const m = a.match(/^--?[\w-]*(key|token|secret)[\w-]*=(.+)$/i);
241
+ const val = m ? m[2] : a;
242
+ if ((m && !isReference(val)) || tokenKind(a)) hits.push({ key: 'args', value: val });
243
+ }
244
+ if (typeof server.url === 'string') {
245
+ const m = server.url.match(/[?&](api[_-]?key|token|key)=([^&]+)/i);
246
+ if (m && !isReference(m[2])) hits.push({ key: 'url query', value: m[2] });
247
+ }
248
+ return hits;
249
+ }
250
+
251
+ export function aiConfigPaths(ctx) {
252
+ const h = ctx.home;
253
+ const appData = join(h, 'AppData', 'Roaming');
254
+ return [
255
+ join(h, '.claude.json'),
256
+ join(h, '.claude', 'settings.json'),
257
+ join(h, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
258
+ join(appData, 'Claude', 'claude_desktop_config.json'),
259
+ join(h, '.config', 'Claude', 'claude_desktop_config.json'),
260
+ join(h, '.cursor', 'mcp.json'),
261
+ join(h, '.codeium', 'windsurf', 'mcp_config.json'),
262
+ join(h, '.config', 'zed', 'settings.json'),
263
+ join(h, '.kiro', 'settings', 'mcp.json'),
264
+ join(h, '.gemini', 'settings.json'),
265
+ join(h, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
266
+ join(appData, 'Code', 'User', 'mcp.json'),
267
+ join(h, '.config', 'Code', 'User', 'mcp.json'),
268
+ ...ctx.projects.flatMap((p) => [join(p, '.mcp.json'), join(p, '.cursor', 'mcp.json'), join(p, '.vscode', 'mcp.json')]),
269
+ ];
270
+ }
271
+
272
+ export function scanAiConfigs(ctx) {
273
+ const out = [];
274
+ for (const f of new Set(aiConfigPaths(ctx))) {
275
+ const text = readText(f);
276
+ if (!text) continue;
277
+ let json;
278
+ try { json = JSON.parse(text); } catch { continue; }
279
+ for (const { name, server } of mcpServersFrom(json)) {
280
+ for (const hit of literalSecretsInServer(server)) {
281
+ out.push({
282
+ id: 'ai.mcp-literal-secret', category: 'ai', severity: 'high',
283
+ title: `MCP server "${name}" has a literal secret in ${hit.key}`,
284
+ location: tilde(ctx, f), fingerprint: fingerprint(hit.value),
285
+ detail: 'Coding-agent and MCP configs are now an explicit target of npm worms. Reference an env var (e.g. "${VAR}") or your OS keychain instead.',
286
+ });
287
+ }
288
+ }
289
+ }
290
+ // Codex CLI auth file stores an API key / refresh token.
291
+ for (const f of [join(ctx.home, '.codex', 'auth.json')]) {
292
+ const t = readText(f);
293
+ if (!t) continue;
294
+ try {
295
+ const j = JSON.parse(t);
296
+ if (j.OPENAI_API_KEY || j.tokens) {
297
+ out.push({
298
+ id: 'ai.codex-auth', category: 'ai', severity: 'medium',
299
+ title: 'Codex CLI credentials file', location: tilde(ctx, f),
300
+ detail: 'Contains an API key or refresh token. Sign out (`codex logout`) and back in to rotate if exposed.',
301
+ });
302
+ }
303
+ } catch {}
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /* ---------------- 7. SSH keys ---------------- */
309
+ export function sshKeyEncrypted(buf) {
310
+ const text = buf.toString('latin1');
311
+ if (/-----BEGIN (RSA|EC|DSA) PRIVATE KEY-----/.test(text)) return /Proc-Type:\s*4,ENCRYPTED/.test(text);
312
+ if (/-----BEGIN ENCRYPTED PRIVATE KEY-----/.test(text)) return true;
313
+ if (/-----BEGIN PRIVATE KEY-----/.test(text)) return false;
314
+ const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/);
315
+ if (!m) return null;
316
+ const raw = Buffer.from(m[1].replace(/\s+/g, ''), 'base64');
317
+ const magic = 'openssh-key-v1\0';
318
+ if (raw.subarray(0, magic.length).toString('latin1') !== magic) return null;
319
+ const len = raw.readUInt32BE(magic.length);
320
+ const cipher = raw.subarray(magic.length + 4, magic.length + 4 + len).toString('latin1');
321
+ return cipher !== 'none';
322
+ }
323
+
324
+ export function scanSsh(ctx) {
325
+ const out = [];
326
+ const dir = join(ctx.home, '.ssh');
327
+ for (const ent of listDir(dir)) {
328
+ if (!ent.isFile() || ent.name.endsWith('.pub') || /^(known_hosts|config|authorized_keys)/.test(ent.name)) continue;
329
+ const p = join(dir, ent.name);
330
+ const buf = readBytes(p);
331
+ if (!buf || !buf.includes('PRIVATE KEY-----')) continue;
332
+ const enc = sshKeyEncrypted(buf);
333
+ if (enc) continue;
334
+ out.push({
335
+ id: 'ssh.unencrypted-key', category: 'ssh', severity: 'high',
336
+ title: `SSH private key without a passphrase: ${ent.name}`, location: tilde(ctx, p),
337
+ ageDays: days(mtime(p)),
338
+ detail: 'A copied file is a working key. Add a passphrase (`ssh-keygen -p -f <key>`) and use ssh-agent, or a hardware-backed key.',
339
+ });
340
+ }
341
+ return out;
342
+ }
343
+
344
+ /* ---------------- 8. .env files in your projects ---------------- */
345
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'vendor', '.venv', 'venv', '__pycache__', 'coverage', '.cache']);
346
+
347
+ export function parseDotenv(text) {
348
+ const pairs = [];
349
+ for (const line of text.split(/\r?\n/)) {
350
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/);
351
+ if (!m) continue;
352
+ let v = m[2].trim();
353
+ if (/^(['"]).*\1$/.test(v)) v = v.slice(1, -1);
354
+ else v = v.replace(/\s+#.*$/, '');
355
+ pairs.push([m[1], v]);
356
+ }
357
+ return pairs;
358
+ }
359
+
360
+ export function scanDotenv(ctx) {
361
+ const out = [];
362
+ for (const root of ctx.projects) {
363
+ for (const f of walk(root, { maxDepth: ctx.depth, skip: SKIP_DIRS })) {
364
+ const base = f.split(/[\\/]/).pop();
365
+ if (!/^\.env(\..+)?$/.test(base) || /\.(example|sample|template|dist|defaults)$/i.test(base)) continue;
366
+ const text = readText(f);
367
+ if (!text) continue;
368
+ const secrets = parseDotenv(text).filter(([k, v]) => isSecretPair(k, v));
369
+ if (!secrets.length) continue;
370
+ const kinds = [...new Set(secrets.map(([, v]) => tokenKind(v)).filter(Boolean))];
371
+ const live = kinds.some((k) => /live|AWS|npm|GitHub|connection string/.test(k));
372
+ out.push({
373
+ id: 'dotenv.secrets', category: 'dotenv', severity: live ? 'high' : 'medium',
374
+ title: `${secrets.length} secret(s) in ${relative(root, f).replace(/\\/g, '/') || base}`,
375
+ location: tilde(ctx, f),
376
+ names: secrets.map(([k]) => k),
377
+ detail: `Keys: ${secrets.map(([k]) => k).join(', ')}${kinds.length ? ` — recognised: ${kinds.join(', ')}` : ''}. ` +
378
+ 'Install scripts run with your project as cwd, so .env files are the first thing they read.',
379
+ });
380
+ }
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /* ---------------- Hardening checks (not secrets, but they shrink the blast radius) ---------------- */
386
+ export function scanHardening(ctx) {
387
+ const out = [];
388
+ const npmrcs = [join(ctx.home, '.npmrc'), ...ctx.projects.map((p) => join(p, '.npmrc'))].map(readText).filter(Boolean).join('\n');
389
+ if (!/^\s*ignore-scripts\s*=\s*true/m.test(npmrcs)) {
390
+ out.push({
391
+ id: 'hardening.ignore-scripts', category: 'hardening', severity: 'low',
392
+ title: 'npm runs dependency install scripts', location: '~/.npmrc',
393
+ 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.',
394
+ });
395
+ }
396
+ const pnpmWs = ctx.projects.map((p) => readText(join(p, 'pnpm-workspace.yaml'))).filter(Boolean).join('\n');
397
+ if (!/min-release-age|minimum-release-age/.test(npmrcs) && !/minimumReleaseAge/.test(pnpmWs)) {
398
+ out.push({
399
+ id: 'hardening.release-age', category: 'hardening', severity: 'low',
400
+ title: 'No dependency cooldown configured', location: '.npmrc',
401
+ detail: 'Malicious versions are usually pulled within hours. `min-release-age=3` (npm ≥ 11.10, days) or `minimumReleaseAge: 4320` (pnpm, minutes) keeps you out of that window.',
402
+ });
403
+ }
404
+ return out;
405
+ }
406
+
407
+ export const SCANNERS = {
408
+ persistence: scanPersistence,
409
+ npm: scanNpmrc,
410
+ github: scanGitHub,
411
+ cloud: scanCloud,
412
+ kubernetes: scanKubeDocker,
413
+ env: scanEnv,
414
+ ai: scanAiConfigs,
415
+ ssh: scanSsh,
416
+ dotenv: scanDotenv,
417
+ hardening: scanHardening,
418
+ };
package/src/util.js ADDED
@@ -0,0 +1,126 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, statSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /** Short, non-reversible fingerprint so users can tell secrets apart without seeing them. */
6
+ export function fingerprint(value) {
7
+ return 'sha256:' + createHash('sha256').update(String(value)).digest('hex').slice(0, 8);
8
+ }
9
+
10
+ export function readText(path) {
11
+ try {
12
+ const st = statSync(path);
13
+ if (!st.isFile() || st.size > 5 * 1024 * 1024) return null;
14
+ return readFileSync(path, 'utf8');
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function readBytes(path) {
21
+ try {
22
+ const st = statSync(path);
23
+ if (!st.isFile() || st.size > 1024 * 1024) return null;
24
+ return readFileSync(path);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export function listDir(path) {
31
+ try {
32
+ return readdirSync(path, { withFileTypes: true });
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ export function exists(path) {
39
+ try {
40
+ statSync(path);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export function mtime(path) {
48
+ try {
49
+ return statSync(path).mtime;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** True when a value is an indirection (env reference, keychain helper, empty) rather than a literal secret. */
56
+ export function isReference(value) {
57
+ if (value == null) return true;
58
+ const v = String(value).trim();
59
+ if (v === '') return true;
60
+ if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(v)) return true; // $VAR or ${VAR}
61
+ if (/^\$\{(env|input):[^}]+\}$/.test(v)) return true; // ${env:VAR} / ${input:id} (VS Code)
62
+ if (/^(op|vault|keychain|secret|1password):\/\//i.test(v)) return true;
63
+ if (/^<[^>]+>$/.test(v) || /^\[[^\]]+\]$/.test(v)) return true; // <placeholder> / [YOUR_KEY]
64
+ if (/^(your|enter|insert|replace|add)[-_ ]?(your[-_ ]?)?[a-z_ -]*(here)?$/i.test(v) || /_here$/i.test(v)) return true;
65
+ if (/^\$\{\{.*\}\}$/.test(v)) return true; // ${{ secrets.X }} (GitHub Actions)
66
+ if (/^(x{4,}|\*{4,}|your[-_ ].*|changeme|placeholder|todo|redacted)$/i.test(v)) return true;
67
+ return false;
68
+ }
69
+
70
+ const SECRET_NAME = /(^|_|-)(api[-_]?key|apikey|token|secret|password|passwd|pwd|private[-_]?key|access[-_]?key|auth|credentials?|dsn|webhook[-_]?secret|client[-_]?secret|session[-_]?key)($|_|-)|^(database_url|db_url|redis_url|mongodb_uri|mongo_url|postgres_url|pg_url|amqp_url|supabase_service_role_key|stripe_.*|openai_.*key|anthropic_.*key)$/i;
71
+ const NOT_SECRET_NAME = /(^|_)(public|publishable|anon|pk|next_public|vite|expo_public|token_url|auth_url|auth_domain|endpoint|region|ttl|expir[a-z]*|length|limit|max|timeout|path|file|enabled|disable|issuer|audience)(_|$)/i;
72
+
73
+ export function looksLikeSecretName(name) {
74
+ return SECRET_NAME.test(name) && !NOT_SECRET_NAME.test(name);
75
+ }
76
+
77
+ const URL_NAME = /(_url|_uri|_dsn)$|^(dsn)$/i;
78
+
79
+ /** Does this name/value pair hold a literal secret? URL-style names only count when they embed credentials. */
80
+ export function isSecretPair(name, value) {
81
+ if (value == null || isReference(value)) return false;
82
+ const v = String(value);
83
+ if (tokenKind(v)) return true;
84
+ if (URL_NAME.test(name)) return /^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^@\s]+@/i.test(v) || /[?&](password|token|key|secret)=[^&\s]+/i.test(v);
85
+ return looksLikeSecretName(name) && v.length >= 8;
86
+ }
87
+
88
+ /** Recognise well-known token formats. Returns a provider label or null. */
89
+ export function tokenKind(value) {
90
+ const v = String(value);
91
+ const rules = [
92
+ [/^npm_[A-Za-z0-9]{36}$/, 'npm token'],
93
+ [/^gh[pousr]_[A-Za-z0-9]{36,}$/, 'GitHub token'],
94
+ [/^github_pat_[A-Za-z0-9_]{60,}$/, 'GitHub fine-grained token'],
95
+ [/^(AKIA|ASIA)[A-Z0-9]{16}$/, 'AWS access key'],
96
+ [/^sk-ant-[A-Za-z0-9_-]{20,}$/, 'Anthropic key'],
97
+ [/^sk-(proj-)?[A-Za-z0-9_-]{20,}$/, 'OpenAI-style key'],
98
+ [/^sk_live_[A-Za-z0-9]{16,}$/, 'Stripe live secret key'],
99
+ [/^rk_live_[A-Za-z0-9]{16,}$/, 'Stripe restricted key'],
100
+ [/^whsec_[A-Za-z0-9]{16,}$/, 'Stripe webhook secret'],
101
+ [/^xox[baprs]-[A-Za-z0-9-]{10,}$/, 'Slack token'],
102
+ [/^AIza[0-9A-Za-z_-]{35}$/, 'Google API key'],
103
+ [/^glpat-[A-Za-z0-9_-]{20,}$/, 'GitLab token'],
104
+ [/^hf_[A-Za-z0-9]{30,}$/, 'Hugging Face token'],
105
+ [/^(postgres(ql)?|mysql|mongodb(\+srv)?|redis|rediss|amqps?):\/\/[^:\s]+:[^@\s]+@/, 'connection string with password'],
106
+ ];
107
+ for (const [re, label] of rules) if (re.test(v)) return label;
108
+ return null;
109
+ }
110
+
111
+ export function walk(root, { maxDepth = 4, skip = new Set() } = {}) {
112
+ const out = [];
113
+ const stack = [[root, 0]];
114
+ while (stack.length) {
115
+ const [dir, depth] = stack.pop();
116
+ for (const ent of listDir(dir)) {
117
+ const p = join(dir, ent.name);
118
+ if (ent.isDirectory()) {
119
+ if (depth < maxDepth && !skip.has(ent.name)) stack.push([p, depth + 1]);
120
+ } else if (ent.isFile()) {
121
+ out.push(p);
122
+ }
123
+ }
124
+ }
125
+ return out;
126
+ }