secret-blast-radius 0.1.0 → 0.2.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 CHANGED
@@ -3,123 +3,152 @@
3
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
4
  [![npm](https://img.shields.io/npm/v/secret-blast-radius)](https://www.npmjs.com/package/secret-blast-radius)
5
5
 
6
- **If a malicious `npm install` ran on this machine right now, what would it get, and what would you rotate first?**
6
+ **One bad `npm install` can read every secret on your laptop. This tells you what it would get, whether it already happened, and what to rotate first.**
7
7
 
8
8
  ```bash
9
9
  npx secret-blast-radius
10
10
  ```
11
11
 
12
12
  ```
13
+ ✖ CRITICAL Known-malicious package @subql/common@5.8.3 (subql)
14
+ ~/code/indexer/package-lock.json
15
+ Resolved in this lockfile. Treat every machine/CI runner that installed it as compromised…
16
+
13
17
  ✖ CRITICAL Plaintext npm credential for registry.npmjs.org
14
18
  ~/.npmrc sha256:3f1c9a0b
15
19
  Any install script can read this and republish every package you maintain (this is how Shai-Hulud spreads).
16
20
 
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.
21
+ ✖ CRITICAL VS Code task "eslint-check" auto-runs on folder open and executes a non-code file
22
+ ~/code/old-portfolio/.vscode/tasks.json
23
+ Matches the GhostAction / PolinRider launcher (tasks.json → `node public/fonts/*.woff2`)…
20
24
 
21
25
  ▲ HIGH MCP server "github" has a literal secret in env.GITHUB_PERSONAL_ACCESS_TOKEN
22
26
  ~/.cursor/mcp.json sha256:9b07e6c1
23
27
 
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.
28
+ ● MEDIUM api deploys to Railway (railway.toml)
29
+ Railway disclosed on Oct 21, 2026 that env vars may have been read and decrypted…
26
30
 
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
31
+ Summary: 3 critical, 1 high, 1 medium, 0 low.
32
+ Rotation order if this machine is ever compromised: 1. persistence 2. npm 3. ai 4. paas
29
33
  ```
30
34
 
31
- ## Why
35
+ *(Illustrative output: fingerprints and paths are examples.)*
32
36
 
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)).
37
+ ## Why this exists
39
38
 
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).
39
+ The last few weeks, in order:
42
40
 
43
- What's different from existing tools:
41
+ | When (2026) | What | What it took |
42
+ |---|---|---|
43
+ | Oct 5 | [`@subql/common@5.8.3`](https://osv.dev/vulnerability/MAL-2026-17571) shipped through the project's own trusted-publishing workflow | AWS (SSM + Secrets Manager, all regions), GitHub, npm, SSH, K8s, Vault. Runs on import too, so `--ignore-scripts` isn't enough |
44
+ | Oct 7–15 | [GhostAction](https://thehackernews.com/2026/10/credential-stealing-github-actions.html): ~1,900 hijacked GitHub accounts pushed "security audit" workflows to 30,000+ repos, then a [VS Code fake-font stealer](https://opensourcemalware.com/blog/ghostaction-copycats-polinrider-by-hiding-a-stealer-in-a-fake-font-file) | Actions secrets, everything in git history, `~/.npmrc`, `~/.aws`, `~/.netrc`, SSH keys, Claude/Gemini logins, plus a cron backdoor |
45
+ | Oct 8 | [`tensorlake@0.5.144`](https://github.com/tensorlakeai/tensorlake/security/advisories/GHSA-8g63-53c9-65j2) (Mini Shai-Hulud) | Everything above plus AI-tool and MCP configs. Installs `gh-token-monitor`, which **wipes your home directory if you revoke the stolen GitHub token** |
46
+ | Oct 21 | [Railway](https://blog.railway.com/p/security-disclosure-wednesday-october-21st): customer env vars may have been decrypted | Every secret you stored on the platform |
47
+ | Oct 21 | [PHP2Shell](https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching) (CVE-2026-109618/-109619), unauthenticated RCE | Whatever your PHP servers can reach |
44
48
 
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.
49
+ Every advisory ends the same way: *"rotate everything the machine could access."* Hardly anyone has that list. This tool builds it, checks for the known infections, and puts the steps in an order that won't make things worse.
52
50
 
53
- ## Safety
51
+ ## What it checks
52
+
53
+ **Your credentials** (what a stealer reads first):
54
+
55
+ | Scanner | Looks at |
56
+ |---|---|
57
+ | `npm` | `~/.npmrc`, project `.npmrc`, `~/.pypirc`: literal tokens (not `${VAR}`) |
58
+ | `github` | gh `hosts.yml` (plaintext fallback), `~/.git-credentials`, `~/.netrc` |
59
+ | `cloud` | `~/.aws/credentials` (long-lived vs session), gcloud ADC, Azure token cache |
60
+ | `kubernetes` | `~/.kube/config` embedded tokens/keys, `~/.docker/config.json` without `credsStore` |
61
+ | `ai` | MCP configs for Claude Code/Desktop, Cursor, Windsurf, VS Code, Zed, Kiro, Gemini; Claude, Gemini and Codex CLI logins |
62
+ | `ssh` | Private keys without a passphrase (PEM, PKCS#8, OpenSSH) |
63
+ | `dotenv` | `.env*` files in your projects (names only, never values) |
64
+ | `env` | Variables a CI step inherits (`NPM_TOKEN`, `AWS_*`, recognised token formats) |
65
+ | `paas` | Railway, Vercel, Fly, Render, Netlify CLI tokens; Heroku in `~/.netrc`; which projects deploy where |
66
+
67
+ **Signs you're already hit:**
68
+
69
+ | Scanner | Detects |
70
+ |---|---|
71
+ | `malicious` | Known-bad versions in `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml` (v5–v9), `yarn.lock` (classic + Berry), `bun.lock`, and in `node_modules`: tensorlake + native packages, `@subql/common@5.8.3`, `@7nohe/openapi-react-query-codegen` (10 versions), the keyv/cacheable wave; the `@opensearch/setup` worm marker |
72
+ | `persistence` | `gh-token-monitor` (systemd unit, LaunchAgent, Windows dir, script) |
73
+ | `host-persistence` | Crontab lines that pipe a server into `bash` (GhostAction `#ghc2`), the `manifest-cache.js --warm` process and `/tmp/tmp.ts018051808.lock` (@subql) |
74
+ | `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 |
75
+
76
+ **Exposure to this month's other incidents:**
54
77
 
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.
78
+ | Scanner | Detects |
79
+ |---|---|
80
+ | `php` | PHP2Shell-vulnerable PHP in Dockerfiles, compose files, `composer.json` platform pins and the local `php` binary. Distro builds are flagged *high, verify backport* rather than critical |
81
+ | `certs` | PEM/CRT files and `NODE_EXTRA_CA_CERTS` bundles containing or chaining to the distrusted **emSign ECC Root CA - G3** (eMudhra) |
82
+ | `install-scripts` | Which dependencies run install scripts, straight from the lockfile |
83
+ | `hardening` | No `ignore-scripts`, no dependency cooldown (`min-release-age` / `minimumReleaseAge`) |
59
84
 
60
- ## Usage
85
+ ## Incident mode
61
86
 
62
87
  ```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
88
+ npx secret-blast-radius --list-incidents
89
+ npx secret-blast-radius --incident tensorlake # or: shai-hulud, subql, ghostaction, railway, php2shell, emudhra, sbi
70
90
  ```
71
91
 
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) |
92
+ Runs only the checks that matter for that incident and prints a sourced checklist, marking what's confirmed and what's still under investigation. `--runbook` turns it into a Markdown checklist you can paste into an incident ticket.
81
93
 
82
- ### In CI: what does `npm install` get to see?
94
+ ## In CI and GitHub code scanning
83
95
 
84
96
  ```yaml
85
- - run: npx secret-blast-radius@0 --only env,npm --fail-on critical
97
+ permissions:
98
+ contents: read
99
+ security-events: write
100
+ steps:
101
+ - uses: actions/checkout@v4
102
+ - uses: Krrishkumar125/secret-blast-radius@v0.2.0
103
+ with:
104
+ fail-on: critical
105
+ - uses: github/codeql-action/upload-sarif@v3
106
+ if: always()
107
+ with:
108
+ sarif_file: secret-blast-radius.sarif
86
109
  ```
87
110
 
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).
111
+ Or without the action: `npx secret-blast-radius@0 --only malicious,repo,php,env --fail-on critical --sarif-file sbr.sarif`.
112
+ SARIF only gets file locations for findings inside the repo. Home-directory findings are reported without a path.
90
113
 
91
- ## What it checks
114
+ ## Safety
92
115
 
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.
116
+ - **Read-only.** It only writes files you name (`--runbook-file`, `--sarif-file`).
117
+ - **Offline.** It makes no network requests. The IOC list is bundled (`src/iocs.json`, dated). Add your own with `--ioc-file extra.json`.
118
+ - **Secret values are never printed.** Findings show a location and a `sha256:xxxxxxxx` fingerprint. Every output format is tested for leaks.
119
+ - **Zero dependencies.** Helper commands (`php -r`, `crontab -l`, `ps -eo args`) are run read-only with a timeout; `--no-exec` disables them.
107
120
 
108
- ## Programmatic API
121
+ ## Options
109
122
 
110
- ```js
111
- import { scan, runbook, failsAt, formatMarkdown } from 'secret-blast-radius';
123
+ | Option | Meaning |
124
+ |---|---|
125
+ | `[project-dir…]` | Projects to scan for `.env`, lockfiles, Dockerfiles, workflows (default: cwd) |
126
+ | `--incident <name>`, `--list-incidents` | Incident checklists |
127
+ | `--json`, `--sarif`, `--sarif-file <f>` | Machine output |
128
+ | `--runbook`, `--runbook-file <f>` | Markdown rotation checklist |
129
+ | `--fail-on <level>` | Exit 1 at/above `critical`, `high`, `medium`, `low` (default `none`) |
130
+ | `--only` / `--skip <list>` | Choose scanners (names in the tables above) |
131
+ | `--ioc-file <f>` | Extra IOCs, same shape as `src/iocs.json` (repeatable) |
132
+ | `--no-env`, `--no-exec`, `--depth <n>`, `--home <dir>`, `--no-color` | Tuning |
112
133
 
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
- ```
134
+ Exit codes: `0` ok, `1` findings at/above `--fail-on`, `2` usage error.
135
+
136
+ ## Keeping false positives low
137
+
138
+ These are skipped on purpose: `${VAR}`, `${{ secrets.X }}`, `op://…`, `[YOUR_KEY]`, `your_api_key_here`, `not-a-secret`, prose values like `this-is-a-secret-value`, credential-free URLs (`redis://localhost`, `file:./dev.db`), `.env.example/.sample/.template/.demo`, `node_modules`, ordinary `folderOpen` tasks, and Claude hooks that run normal tools. Every rule above came from running the tool on real repos (TanStack Router, symfony/demo, vercel/next.js, docker-library/wordpress, Railway templates). If you hit a false positive, please open an issue.
117
139
 
118
140
  ## Limits
119
141
 
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.
142
+ - It checks well-known locations. It can't see your OS keychain (that's the point) or browser profiles.
143
+ - The IOC list covers the incidents above, not every malicious package ever published. Pair it with `npm audit`, OSV or Socket.
144
+ - For distro PHP builds it can't tell whether your vendor backported the fix.
145
+ - A clean report shrinks your blast radius. It doesn't prove a machine is clean.
122
146
 
123
- ## License
147
+ ## Programmatic API
148
+
149
+ ```js
150
+ import { scan, runbook, toSarif, INCIDENTS } from 'secret-blast-radius';
151
+ const result = scan({ projects: ['.'], incident: 'railway', exec: false });
152
+ ```
124
153
 
125
154
  MIT © Krrish Kumar
package/action.yml ADDED
@@ -0,0 +1,41 @@
1
+ name: secret-blast-radius
2
+ description: Find what a malicious install could steal in this job and repo (worm IOCs, GhostAction workflows, PHP2Shell, exposed CI secrets). Read-only; never prints secrets.
3
+ author: Krrish Kumar
4
+ branding:
5
+ icon: shield
6
+ color: red
7
+ inputs:
8
+ only:
9
+ description: Comma list of scanners. Default is the set that makes sense on a CI runner.
10
+ default: malicious,repo,php,certs,install-scripts,env,npm,dotenv
11
+ fail-on:
12
+ description: critical | high | medium | low | none
13
+ default: critical
14
+ incident:
15
+ description: Optional incident name (php2shell, railway, tensorlake, shai-hulud, subql, ghostaction, emudhra, sbi)
16
+ default: ''
17
+ sarif-file:
18
+ description: Where to write SARIF (upload it with github/codeql-action/upload-sarif)
19
+ default: secret-blast-radius.sarif
20
+ version:
21
+ description: npm version or dist-tag of secret-blast-radius to run
22
+ default: '0'
23
+ outputs:
24
+ sarif-file:
25
+ description: Path of the SARIF report
26
+ value: ${{ inputs.sarif-file }}
27
+ runs:
28
+ using: composite
29
+ steps:
30
+ - name: Run secret-blast-radius
31
+ shell: bash
32
+ env:
33
+ SBR_ONLY: ${{ inputs.only }}
34
+ SBR_FAIL_ON: ${{ inputs.fail-on }}
35
+ SBR_INCIDENT: ${{ inputs.incident }}
36
+ SBR_SARIF: ${{ inputs.sarif-file }}
37
+ SBR_VERSION: ${{ inputs.version }}
38
+ run: |
39
+ args=(--only "$SBR_ONLY" --fail-on "$SBR_FAIL_ON" --sarif-file "$SBR_SARIF")
40
+ if [ -n "$SBR_INCIDENT" ]; then args+=(--incident "$SBR_INCIDENT"); fi
41
+ npx --yes "secret-blast-radius@${SBR_VERSION}" "${args[@]}" .
package/bin/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { writeFileSync, readFileSync } from 'node:fs';
4
- import { scan, failsAt, formatText, formatMarkdown, runbook } from '../src/index.js';
4
+ import { scan, failsAt, formatText, formatMarkdown, runbook, toSarif, INCIDENTS } from '../src/index.js';
5
5
 
6
6
  const HELP = `Usage: secret-blast-radius [options] [project-dir...]
7
7
 
@@ -10,10 +10,17 @@ on this machine or CI runner, ranks it, and writes an ordered rotation runbook.
10
10
  Read-only. Never prints secret values.
11
11
 
12
12
  Options:
13
+ --incident <name> Tailored checklist + only the relevant checks. Names:
14
+ php2shell, railway, tensorlake, shai-hulud, subql, ghostaction, emudhra, sbi
15
+ --list-incidents Show incident names and status
13
16
  --json Machine-readable output
17
+ --sarif [file] SARIF 2.1.0 for GitHub code scanning (stdout, or --sarif-file <f>)
18
+ --ioc-file <f> Extra IOC JSON (same shape as src/iocs.json); repeatable
19
+ --no-exec Never run helper commands (php -r, crontab -l, ps)
14
20
  --runbook [file] Print (or write to file) a Markdown rotation runbook
15
21
  --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
22
+ --only <list> Comma list of scanners: persistence,npm,github,cloud,kubernetes,env,ai,ssh,dotenv,
23
+ hardening,malicious,repo,host-persistence,php,paas,certs,install-scripts
17
24
  --skip <list> Comma list of scanners to skip
18
25
  --no-env Do not inspect environment variables
19
26
  --depth <n> How deep to look for .env files in project dirs (default 4)
@@ -28,7 +35,8 @@ try {
28
35
  args = parseArgs({
29
36
  allowPositionals: true,
30
37
  options: {
31
- json: { type: 'boolean' }, runbook: { type: 'boolean' }, 'runbook-file': { type: 'string' },
38
+ json: { type: 'boolean' }, sarif: { type: 'boolean' }, 'sarif-file': { type: 'string' },
39
+ incident: { type: 'string' }, 'list-incidents': { type: 'boolean' }, 'ioc-file': { type: 'string', multiple: true }, 'no-exec': { type: 'boolean' }, runbook: { type: 'boolean' }, 'runbook-file': { type: 'string' },
32
40
  'fail-on': { type: 'string', default: 'none' }, only: { type: 'string' }, skip: { type: 'string' },
33
41
  'no-env': { type: 'boolean' }, depth: { type: 'string' }, home: { type: 'string' },
34
42
  'no-color': { type: 'boolean' }, version: { type: 'boolean', short: 'v' }, help: { type: 'boolean', short: 'h' },
@@ -49,20 +57,31 @@ let runbookFile = o['runbook-file'];
49
57
  let projects = positionals;
50
58
  if (o.runbook && !runbookFile && positionals.length && /\.md$/i.test(positionals[0])) { runbookFile = positionals[0]; projects = positionals.slice(1); }
51
59
 
60
+ if (o['list-incidents']) {
61
+ for (const [k, v] of Object.entries(INCIDENTS)) console.log(`${k.padEnd(12)} ${v.title}\n${' '.repeat(13)}${v.status}`);
62
+ process.exit(0);
63
+ }
52
64
  const list = (s) => (s ? s.split(',').map((x) => x.trim()).filter(Boolean) : undefined);
53
65
  const skip = list(o.skip) ?? [];
54
66
  if (o['no-env']) skip.push('env');
55
67
 
56
68
  let result;
57
69
  try {
58
- result = scan({ projects, home: o.home, only: list(o.only), skip, depth: o.depth ? Number(o.depth) : undefined });
70
+ result = scan({ 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 });
59
71
  failsAt(result, o['fail-on']);
60
72
  } catch (e) {
61
73
  console.error(`secret-blast-radius: ${e.message}`);
62
74
  process.exit(2);
63
75
  }
64
76
 
65
- if (o.json) {
77
+ const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
78
+ if (o.sarif || o['sarif-file']) {
79
+ const sarif = JSON.stringify(toSarif(result, version), null, 2) + '\n';
80
+ if (o['sarif-file']) { writeFileSync(o['sarif-file'], sarif); if (!o.json) console.error(`SARIF written to ${o['sarif-file']}`); } else process.stdout.write(sarif);
81
+ }
82
+ if (o.sarif && !o['sarif-file']) {
83
+ // stdout already used for SARIF
84
+ } else if (o.json) {
66
85
  process.stdout.write(JSON.stringify({ ...result, runbook: runbook(result).map(({ items, ...r }) => ({ ...r, findings: items.map((i) => i.id) })) }, null, 2) + '\n');
67
86
  } else if (o.runbook) {
68
87
  const md = formatMarkdown(result);
package/package.json CHANGED
@@ -1,20 +1,52 @@
1
1
  {
2
2
  "name": "secret-blast-radius",
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.",
3
+ "version": "0.2.0",
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)",
7
- "repository": { "type": "git", "url": "git+https://github.com/Krrishkumar125/secret-blast-radius.git" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Krrishkumar125/secret-blast-radius.git"
10
+ },
8
11
  "homepage": "https://github.com/Krrishkumar125/secret-blast-radius#readme",
9
12
  "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"],
13
+ "keywords": [
14
+ "security",
15
+ "supply-chain",
16
+ "npm",
17
+ "shai-hulud",
18
+ "secrets",
19
+ "credentials",
20
+ "rotation",
21
+ "incident-response",
22
+ "mcp",
23
+ "ci",
24
+ "devsecops",
25
+ "sarif",
26
+ "php2shell",
27
+ "ghostaction",
28
+ "railway",
29
+ "github-action"
30
+ ],
11
31
  "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" },
32
+ "bin": {
33
+ "secret-blast-radius": "bin/cli.js"
34
+ },
35
+ "exports": {
36
+ ".": "./src/index.js"
37
+ },
38
+ "files": [
39
+ "bin",
40
+ "src",
41
+ "action.yml",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18.18"
47
+ },
16
48
  "scripts": {
17
- "test": "node --test test/scanners.test.js test/cli.test.js",
49
+ "test": "node --test test/scanners.test.js test/supply.test.js test/cli.test.js",
18
50
  "check": "node --check bin/cli.js && npm test"
19
51
  }
20
52
  }
package/src/format.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { runbook } from './index.js';
2
+ import { INCIDENTS } from './incidents.js';
2
3
 
3
4
  const ICON = { critical: '✖', high: '▲', medium: '●', low: '·' };
4
5
 
@@ -9,8 +10,14 @@ export function formatText(result, { color = false } = {}) {
9
10
  const { counts } = result;
10
11
  lines.push(c('1', 'secret-blast-radius') + ' — what a malicious install script could steal from this machine right now');
11
12
  lines.push('');
13
+ if (result.incident) {
14
+ const inc = INCIDENTS[result.incident];
15
+ lines.push(c('1', inc.title), c('2', inc.status), '');
16
+ inc.checklist.forEach((x, i) => lines.push(` ${i + 1}. ${x}`));
17
+ lines.push('', ...inc.refs.map((r) => ` ${c('2', r)}`), '', c('1', 'What this machine/project has that matters for this incident:'), '');
18
+ }
12
19
  if (!result.findings.length) {
13
- lines.push('No reachable secrets found in the places checked. Nice.');
20
+ lines.push(result.incident ? 'Nothing relevant found by these checks.' : 'No reachable secrets found in the places checked. Nice.');
14
21
  return lines.join('\n');
15
22
  }
16
23
  for (const f of result.findings) {
@@ -28,6 +35,10 @@ export function formatText(result, { color = false } = {}) {
28
35
 
29
36
  export function formatMarkdown(result) {
30
37
  const out = ['# Secret rotation runbook', '', `Generated ${result.scannedAt} by secret-blast-radius. Values are never included; fingerprints let you match items.`, ''];
38
+ if (result.incident) {
39
+ const inc = INCIDENTS[result.incident];
40
+ out.push(`## Incident: ${inc.title}`, '', `_${inc.status}_`, '', ...inc.checklist.map((x) => `- [ ] ${x}`), '', ...inc.refs.map((r) => `- ${r}`), '');
41
+ }
31
42
  const rb = runbook(result);
32
43
  if (!rb.length) return out.concat('Nothing to rotate: no reachable secrets were found.').join('\n');
33
44
  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.', '');
@@ -0,0 +1,92 @@
1
+ /** Incident playbooks. Every claim here is sourced; `status` marks what is confirmed vs still under investigation. */
2
+ export const INCIDENTS = {
3
+ php2shell: {
4
+ title: 'PHP2Shell — CVE-2026-109618 / CVE-2026-109619 (Oct 21, 2026)',
5
+ status: 'Confirmed by the PHP project and CISA. CISA had not observed exploitation at publication; public PoCs were circulating.',
6
+ scanners: ['php'],
7
+ checklist: [
8
+ 'Inventory every PHP runtime: servers, containers, base images, and products that bundle PHP (CISA: "prevalence may not be directly observable").',
9
+ 'Update to 8.5.12, 8.4.27, 8.3.36 or 8.2.35 (or your distro\'s patched build) and restart every PHP-FPM pool / Apache worker.',
10
+ 'PHP 8.1 and older get no fix: upgrade, or take internet-facing systems offline until you can (CISA).',
11
+ 'Rebuild images with `--pull --no-cache`; a floating `php:8.3` tag does not update images you already built.',
12
+ 'Watch for PHP worker processes spawning shells or downloading tools.',
13
+ ],
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
+ },
16
+ railway: {
17
+ title: 'Railway environment-variable exposure (disclosed Oct 21, 2026)',
18
+ status: 'Confirmed by Railway; scope still being determined ("may be limited to a subset of users"). Railway advises all users to act.',
19
+ scanners: ['paas', 'dotenv', 'env'],
20
+ checklist: [
21
+ 'Roll every environment variable stored in Railway: API keys, database passwords, webhook secrets, OAuth client secrets.',
22
+ 'Rotate private container-registry credentials configured in Railway.',
23
+ 'Review provider logs (database, Stripe, cloud, AI providers) for use of the old keys from unknown IPs.',
24
+ 'Redeploy once builds resume: running services keep the old values until restart.',
25
+ 'Watch for Railway\'s direct email if your account is in the confirmed set, and status.railway.com for updates.',
26
+ ],
27
+ refs: ['https://blog.railway.com/p/security-disclosure-wednesday-october-21st'],
28
+ },
29
+ tensorlake: {
30
+ title: 'tensorlake@0.5.144 — Mini Shai-Hulud worm with gh-token-monitor dead-man\'s switch (Oct 8, 2026)',
31
+ status: 'Confirmed by the maintainer (GHSA-8g63-53c9-65j2), Socket, StepSecurity and Aikido.',
32
+ scanners: ['malicious', 'persistence', 'host-persistence', 'repo', 'npm', 'github', 'cloud', 'kubernetes', 'ssh', 'ai', 'dotenv', 'env'],
33
+ checklist: [
34
+ 'Search lockfiles and CI logs for tensorlake@0.5.144 and tensorlake-native-*@0.5.144 (live 01:12–02:54 UTC, Oct 8).',
35
+ 'If it ran: disconnect, then REMOVE gh-token-monitor before revoking any GitHub token (revocation triggers `rm -rf ~`).',
36
+ 'From a clean machine rotate npm, GitHub, cloud, Kubernetes, Vault, SSH/GPG, .env, AI-tool/MCP credentials.',
37
+ 'Check packages you publish for versions you did not release; check repos for commits by claude@users.noreply.github.com and the branch dependabot/github_actions/format/setup-formatter.',
38
+ 'Rebuild the machine from a trusted image.',
39
+ ],
40
+ refs: ['https://github.com/tensorlakeai/tensorlake/security/advisories/GHSA-8g63-53c9-65j2', 'https://www.stepsecurity.io/blog/tensorlake-npm-compromised-hostage-token-worm'],
41
+ },
42
+ subql: {
43
+ title: '@subql/common@5.8.3 — credential stealer + remote shell (Oct 5, 2026)',
44
+ status: 'Confirmed: GHSA-9333-3c4x-x3h5 / MAL-2026-17571; analysed by StepSecurity and GMO Flatt Security.',
45
+ scanners: ['malicious', 'host-persistence', 'cloud', 'github', 'npm', 'ssh', 'kubernetes', 'env', 'dotenv'],
46
+ checklist: [
47
+ 'Search lockfiles, caches and CI logs for @subql/common@5.8.3. 5.8.2 is the last known-good release.',
48
+ '`--ignore-scripts` is NOT enough: importing the package also starts the payload.',
49
+ 'Look for a running `manifest-cache.js --warm` process and /tmp/tmp.ts018051808.lock; block ci-artifacts.dev.',
50
+ 'Rotate AWS credentials first (SSM Parameter Store and Secrets Manager were read across all regions), then GitHub, npm, SSH, Kubernetes, Vault.',
51
+ 'Rebuild affected hosts/runners; uninstalling does not stop the detached worker.',
52
+ ],
53
+ refs: ['https://osv.dev/vulnerability/MAL-2026-17571', 'https://www.stepsecurity.io/blog/subql-ecosystem-compromised', 'https://flatt.tech/research/posts/subql-common-npm-supply-chain-attack/'],
54
+ },
55
+ ghostaction: {
56
+ title: 'GhostAction — malicious "security audit" workflows + VS Code fake-font stealer (Oct 7–15, 2026)',
57
+ status: 'Confirmed by StepSecurity, Socket, GitGuardian and OpenSourceMalware. ~1,900 accounts, 30,000+ repos targeted.',
58
+ scanners: ['repo', 'host-persistence', 'github', 'npm', 'cloud', 'kubernetes', 'ssh', 'ai'],
59
+ checklist: [
60
+ 'Revoke GitHub sessions, PATs, OAuth grants (incl. client ID Ov23ctrqCqZ79UaYKwyq), SSH and deploy keys FIRST: deleting the workflow does not revoke the token that pushed it.',
61
+ 'In every repo you can write to, remove workflows contacting 193.32.204.199 (security-audit.yml, lint.yml, ci-check.yml, …), .vscode/tasks.json "eslint-check" tasks and public/fonts/*.woff2 files that are really JS.',
62
+ 'Rotate the repo\'s Actions secrets and every credential ever committed to any branch (Stage 1 reads `git log -p --all`).',
63
+ 'If you opened an infected repo in VS Code with automatic tasks allowed: run `crontab -l` and remove lines ending in #ghc2, then rotate from a clean device.',
64
+ 'Require approval for workflow runs and protect .github/workflows with required reviews.',
65
+ ],
66
+ refs: ['https://thehackernews.com/2026/10/credential-stealing-github-actions.html', 'https://opensourcemalware.com/blog/ghostaction-copycats-polinrider-by-hiding-a-stealer-in-a-fake-font-file', 'https://blog.gitguardian.com/ghostaction-github-actions-supply-chain-attack-returns/'],
67
+ },
68
+ emudhra: {
69
+ title: 'eMudhra — weak keys in 8 intermediates under emSign ECC Root CA - G3 (reported Oct 17–18, 2026)',
70
+ status: 'Confirmed by Google, Mozilla and eMudhra. eMudhra says no certificates were issued from the intermediates; root cause still under investigation.',
71
+ scanners: ['certs'],
72
+ checklist: [
73
+ 'Website operators: if your TLS chain ends at emSign ECC Root CA - G3, get a certificate from another CA before Chrome removes the root (Oct 27, 2026 PDT).',
74
+ 'Remove emSign ECC Root CA - G3 from custom CA bundles (NODE_EXTRA_CA_CERTS, Java truststores, pinned bundles in apps/IoT).',
75
+ 'Clients that do not enforce Certificate Transparency or consume CRLSets/OneCRL need a trust-store update to stop trusting the intermediates.',
76
+ 'eMudhra states its Indian DSC/eSign services run on separate hierarchies and are unaffected.',
77
+ ],
78
+ refs: ['https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/', 'https://emudhra.com/en/post/certificate-security-incident/'],
79
+ },
80
+ sbi: {
81
+ title: 'SBI — customer records found on the dark web (disclosed Oct 21, 2026)',
82
+ status: 'Under investigation. SBI\'s preliminary analysis of ~375,000 records found 3,897 customer records with PII; source not yet determined.',
83
+ scanners: ['dotenv', 'env', 'ai'],
84
+ checklist: [
85
+ 'Nothing to scan on a developer machine for this one: it is a data exposure, not a software flaw.',
86
+ 'Customers: expect targeted calls/SMS using real names, phone numbers, addresses and loan details. Banks never ask for OTP, PIN or remote-access apps.',
87
+ 'Fintech/lending developers: CERT-In\'s Oct 9 and Oct 15 advisories cite exposed credentials, test APIs and plaintext payment-API keys. The secrets inventory below is where to start.',
88
+ ],
89
+ refs: ['https://economictimes.indiatimes.com/industry/banking/finance/banking/sbi-probes-suspected-data-breach-after-3-75-lakh-records-surface-on-dark-web/articleshow/135083347.cms'],
90
+ },
91
+ };
92
+ INCIDENTS['shai-hulud'] = { ...INCIDENTS.tensorlake, title: 'Mini Shai-Hulud npm worm family (keyv/cacheable Aug 2026, @7nohe/openapi-react-query-codegen, tensorlake Oct 2026)' };
package/src/index.js CHANGED
@@ -1,6 +1,20 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { resolve } from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
3
4
  import { SCANNERS } from './scanners.js';
5
+ import { SUPPLY_SCANNERS, loadIocs } from './supply.js';
6
+ import { INCIDENTS } from './incidents.js';
7
+
8
+ export const ALL_SCANNERS = { ...SCANNERS, ...SUPPLY_SCANNERS };
9
+
10
+ function defaultExec(cmd, args) {
11
+ try {
12
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 4000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
13
+ return r.status === 0 ? r.stdout : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
4
18
 
5
19
  export const SEVERITY = ['critical', 'high', 'medium', 'low'];
6
20
 
@@ -50,6 +64,21 @@ export const ROTATION = {
50
64
  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
65
  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
66
  },
67
+ paas: {
68
+ order: 5.5, label: 'Deploy platforms (Railway, Vercel, Fly, Render, Heroku, Netlify)',
69
+ steps: ['Rotate every secret stored in the platform\'s env store at its provider (DB, Stripe, AI keys), then update the variable.', 'Redeploy: services read env vars at start-up.', 'Log the CLI out and back in, and revoke old platform tokens.'],
70
+ url: 'https://blog.railway.com/p/security-disclosure-wednesday-october-21st',
71
+ },
72
+ php: {
73
+ order: 9, label: 'Patch PHP (PHP2Shell)',
74
+ steps: ['Update to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 or later; PHP ≤ 8.1 must be upgraded.', 'Rebuild images with --pull --no-cache and restart all PHP workers.'],
75
+ url: 'https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching',
76
+ },
77
+ certs: {
78
+ order: 10, label: 'Replace certificates under emSign ECC Root CA - G3',
79
+ steps: ['Issue a replacement certificate from another CA before Oct 27, 2026.', 'Remove the root from custom trust bundles.'],
80
+ url: 'https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/',
81
+ },
53
82
  dotenv: {
54
83
  order: 8, label: 'Application secrets in .env files',
55
84
  steps: ['Rotate database passwords, payment keys and webhook secrets at each provider.', 'Redeploy: most platforms only read env vars at process start.'],
@@ -63,17 +92,27 @@ export function scan(options = {}) {
63
92
  projects: (options.projects?.length ? options.projects : [process.cwd()]).map((p) => resolve(p)),
64
93
  env: options.env ?? process.env,
65
94
  depth: options.depth ?? 4,
95
+ iocs: options.iocs ?? loadIocs(options.iocFiles ?? []),
96
+ exec: options.exec === false ? null : options.exec ?? defaultExec,
97
+ checkTmp: options.checkTmp ?? options.home === undefined,
66
98
  };
67
- const only = options.only ? new Set(options.only) : null;
99
+ let onlyList = options.only;
100
+ if (options.incident) {
101
+ const inc = INCIDENTS[options.incident];
102
+ if (!inc) throw new Error(`Unknown incident "${options.incident}". Use one of: ${Object.keys(INCIDENTS).join(', ')}`);
103
+ onlyList = onlyList ? onlyList.filter((s) => inc.scanners.includes(s)) : inc.scanners;
104
+ }
105
+ for (const s of [...(onlyList ?? []), ...(options.skip ?? [])]) if (!ALL_SCANNERS[s]) throw new Error(`Unknown scanner "${s}". Use: ${Object.keys(ALL_SCANNERS).join(', ')}`);
106
+ const only = onlyList ? new Set(onlyList) : null;
68
107
  const skip = new Set(options.skip ?? []);
69
108
  const findings = [];
70
- for (const [name, fn] of Object.entries(SCANNERS)) {
109
+ for (const [name, fn] of Object.entries(ALL_SCANNERS)) {
71
110
  if ((only && !only.has(name)) || skip.has(name)) continue;
72
111
  findings.push(...fn(ctx));
73
112
  }
74
113
  findings.sort((a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity) || (ROTATION[a.category]?.order ?? 99) - (ROTATION[b.category]?.order ?? 99));
75
114
  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 };
115
+ return { scannedAt: new Date().toISOString(), home: ctx.home, projects: ctx.projects, iocsUpdated: ctx.iocs.updated, incident: options.incident, counts, findings };
77
116
  }
78
117
 
79
118
  export function runbook(result) {
@@ -89,3 +128,6 @@ export function failsAt(result, level) {
89
128
  }
90
129
 
91
130
  export { formatText, formatMarkdown } from './format.js';
131
+ export { toSarif } from './sarif.js';
132
+ export { INCIDENTS } from './incidents.js';
133
+ export { loadIocs, parseLockfile, phpStatus } from './supply.js';