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/src/format.js CHANGED
@@ -16,6 +16,7 @@ export function formatText(result, { color = false } = {}) {
16
16
  inc.checklist.forEach((x, i) => lines.push(` ${i + 1}. ${x}`));
17
17
  lines.push('', ...inc.refs.map((r) => ` ${c('2', r)}`), '', c('1', 'What this machine/project has that matters for this incident:'), '');
18
18
  }
19
+ if (result.osvQueried != null) lines.push(c('2', `OSV: checked ${result.osvQueried} package versions against MAL- advisories (online, opt-in).`), '');
19
20
  if (!result.findings.length) {
20
21
  lines.push(result.incident ? 'Nothing relevant found by these checks.' : 'No reachable secrets found in the places checked. Nice.');
21
22
  return lines.join('\n');
@@ -45,7 +46,10 @@ export function formatMarkdown(result) {
45
46
  rb.forEach((step, i) => {
46
47
  out.push(`## ${i + 1}. ${step.label}`, '');
47
48
  for (const it of step.items) out.push(`- [ ] **${it.title}** — \`${it.location}\`${it.fingerprint ? ` (${it.fingerprint})` : ''}`);
48
- out.push('', ...step.steps.map((s) => `1. ${s}`), '', `Reference: ${step.url}`, '');
49
+ out.push('', ...step.steps.map((s) => `1. ${s}`), '');
50
+ if (step.verify?.length) out.push('**Verify the old credential is dead:**', '', ...step.verify.map((v) => `- ${v}`), '');
51
+ if (step.redeploy) out.push('**Redeploy** after updating: running processes keep the old value until restart.', '');
52
+ out.push(`Docs: ${step.docs.map(([t, u]) => `[${t}](${u})`).join(' · ')}`, '');
49
53
  });
50
54
  return out.join('\n');
51
55
  }
package/src/incidents.js CHANGED
@@ -13,6 +13,17 @@ export const INCIDENTS = {
13
13
  ],
14
14
  refs: ['https://thephp.foundation/blog/2026/10/21/todays-php-security-release-and-how-were-strengthening-php-security/', 'https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching'],
15
15
  },
16
+ glassworm: {
17
+ title: 'GlassWorm — malicious and sleeper Open VSX / VS Code extensions (Mar–Oct 2026)',
18
+ status: 'Confirmed by Socket research; extensions removed from Open VSX after reports. Sleeper extensions were benign when published and are flagged as high risk, not proven malicious.',
19
+ scanners: ['extensions', 'persistence', 'host-persistence', 'github', 'npm'],
20
+ checklist: [
21
+ 'Uninstall every flagged extension in every editor (VS Code, Cursor, Windsurf, VSCodium, remote servers). Extension packs can pull a flagged extension in transitively.',
22
+ 'From a clean machine, rotate GitHub, npm and Open VSX tokens and move crypto wallets: these are what GlassWorm steals.',
23
+ 'Turn off extension auto-update for publishers you do not know; prefer verified publishers.',
24
+ ],
25
+ refs: ['https://socket.dev/blog/open-vsx-transitive-glassworm-campaign', 'https://socket.dev/blog/73-open-vsx-sleeper-extensions-glassworm', 'https://socket.dev/blog/glassworm-loader-hits-open-vsx-via-suspected-developer-account-compromise'],
26
+ },
16
27
  railway: {
17
28
  title: 'Railway environment-variable exposure (disclosed Oct 21, 2026)',
18
29
  status: 'Confirmed by Railway; scope still being determined ("may be limited to a subset of users"). Railway advises all users to act.',
@@ -29,7 +40,7 @@ export const INCIDENTS = {
29
40
  tensorlake: {
30
41
  title: 'tensorlake@0.5.144 — Mini Shai-Hulud worm with gh-token-monitor dead-man\'s switch (Oct 8, 2026)',
31
42
  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'],
43
+ scanners: ['malicious', 'npm-cache', 'history', 'ecosystems', 'persistence', 'host-persistence', 'repo', 'npm', 'github', 'cloud', 'kubernetes', 'ssh', 'ai', 'dotenv', 'env'],
33
44
  checklist: [
34
45
  '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
46
  'If it ran: disconnect, then REMOVE gh-token-monitor before revoking any GitHub token (revocation triggers `rm -rf ~`).',
@@ -42,7 +53,7 @@ export const INCIDENTS = {
42
53
  subql: {
43
54
  title: '@subql/common@5.8.3 — credential stealer + remote shell (Oct 5, 2026)',
44
55
  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'],
56
+ scanners: ['malicious', 'npm-cache', 'history', 'ecosystems', 'host-persistence', 'cloud', 'github', 'npm', 'ssh', 'kubernetes', 'env', 'dotenv'],
46
57
  checklist: [
47
58
  'Search lockfiles, caches and CI logs for @subql/common@5.8.3. 5.8.2 is the last known-good release.',
48
59
  '`--ignore-scripts` is NOT enough: importing the package also starts the payload.',
@@ -55,7 +66,7 @@ export const INCIDENTS = {
55
66
  ghostaction: {
56
67
  title: 'GhostAction — malicious "security audit" workflows + VS Code fake-font stealer (Oct 7–15, 2026)',
57
68
  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'],
69
+ scanners: ['repo', 'host-persistence', 'github', 'npm', 'cloud', 'kubernetes', 'ssh', 'ai', 'ecosystems'],
59
70
  checklist: [
60
71
  '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
72
  '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.',
package/src/index.js CHANGED
@@ -4,12 +4,13 @@ import { spawnSync } from 'node:child_process';
4
4
  import { SCANNERS } from './scanners.js';
5
5
  import { SUPPLY_SCANNERS, loadIocs } from './supply.js';
6
6
  import { INCIDENTS } from './incidents.js';
7
+ import { V3_SCANNERS, osvMalicious } from './v3.js';
7
8
 
8
- export const ALL_SCANNERS = { ...SCANNERS, ...SUPPLY_SCANNERS };
9
+ export const ALL_SCANNERS = { ...SCANNERS, ...SUPPLY_SCANNERS, ...V3_SCANNERS };
9
10
 
10
11
  function defaultExec(cmd, args) {
11
12
  try {
12
- const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 4000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
13
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: cmd === 'git' ? 30000 : 4000, maxBuffer: 256 * 1024 * 1024, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
13
14
  return r.status === 0 ? r.stdout : null;
14
15
  } catch {
15
16
  return null;
@@ -18,73 +19,108 @@ function defaultExec(cmd, args) {
18
19
 
19
20
  export const SEVERITY = ['critical', 'high', 'medium', 'low'];
20
21
 
21
- /** Order in which to rotate after a suspected compromise. Lower = earlier. */
22
+ /**
23
+ * Rotation plan v2. Order = what to do first after a suspected compromise (lower = earlier).
24
+ * steps: what to do; verify: how to confirm the old credential is dead; docs: provider docs the text is based on.
25
+ */
22
26
  export const ROTATION = {
23
27
  persistence: {
24
- order: 0, label: 'Stop the worm first',
28
+ order: 0, label: 'Stop the malware first (worm services, malicious packages/extensions)',
25
29
  steps: [
26
- 'Disconnect the machine from the network.',
30
+ 'Disconnect the machine from the network. Do every rotation below from a different, clean machine.',
27
31
  '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.',
28
- 'Do every rotation below from a different, clean machine, then rebuild this one.',
32
+ 'Uninstall flagged editor extensions and remove known-bad package versions; check crontab, systemd user units, LaunchAgents and .vscode/tasks.json for anything you did not add.',
33
+ 'Then rebuild or reimage the machine; removing the package does not undo what its install script already did.',
29
34
  ],
30
- url: 'https://github.com/tensorlakeai/tensorlake/security/advisories/GHSA-8g63-53c9-65j2',
35
+ verify: ['`systemctl --user list-units | grep -i token`, `crontab -l`, `ls ~/Library/LaunchAgents` show nothing unexpected.'],
36
+ docs: [['tensorlake advisory (GHSA-8g63-53c9-65j2)', 'https://github.com/tensorlakeai/tensorlake/security/advisories/GHSA-8g63-53c9-65j2'], ['CISA: widespread npm supply chain compromise', 'https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem']],
31
37
  },
32
38
  npm: {
33
- order: 1, label: 'npm publish tokens (stops the worm republishing your packages)',
34
- 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.'],
35
- url: 'https://docs.npmjs.com/revoking-access-tokens',
39
+ order: 1, label: 'Package registry tokens: npm, PyPI, RubyGems, crates.io, Composer (stops a worm republishing your packages)',
40
+ steps: [
41
+ 'npm: `npm token list`, then `npm token delete <id>` for each (use the token ID, not the truncated token), or npmjs.com → Access Tokens. npm notes revocation can take up to an hour.',
42
+ 'PyPI: delete the token under Account settings → API tokens; recreate it scoped to one project, or switch to Trusted Publishing.',
43
+ 'RubyGems: delete the key at rubygems.org → Settings → API keys (delete `legacy-key` if present); `gem signout` then `gem signin` with a gem-scoped key with an expiry and MFA.',
44
+ 'crates.io: revoke at crates.io/settings/tokens; `cargo logout`, set `registry.global-credential-providers` to an OS keychain provider, `cargo login`.',
45
+ 'Look for versions you did not publish: `npm view <pkg> time`, your PyPI/RubyGems/crates.io project history.',
46
+ 'Move CI publishing to trusted publishing (OIDC). Provenance proves where a package was built, not that it is safe: @subql/common@5.8.3 and tensorlake@0.5.144 both shipped with valid provenance.',
47
+ ],
48
+ verify: ['`npm token list` no longer shows the old ID; `npm whoami` with the old token fails (allow up to an hour).'],
49
+ docs: [['npm: Revoking access tokens', 'https://docs.npmjs.com/revoking-access-tokens'], ['PyPI help: API tokens', 'https://pypi.org/help/#apitoken'], ['RubyGems: API key scopes', 'https://guides.rubygems.org/api-key-scopes/'], ['Cargo: registry authentication', 'https://doc.rust-lang.org/cargo/reference/registry-authentication.html'], ['npm trusted publishing', 'https://docs.npmjs.com/trusted-publishers']],
36
50
  },
37
51
  github: {
38
52
  order: 2, label: 'GitHub tokens and sessions',
39
- 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.'],
40
- url: 'https://github.com/settings/tokens',
53
+ steps: [
54
+ 'Make sure persistence is gone first (step above).',
55
+ 'Revoke PATs at github.com/settings/tokens and OAuth/GitHub App authorizations at /settings/applications; `gh auth logout` then `gh auth login` (stores the new token in the OS keyring).',
56
+ 'Replace classic `ghp_` tokens with fine-grained tokens that have an expiry and only the repos they need.',
57
+ 'Review your security log for `oauth_authorization` and token events, and repos for commits, branches or workflows you did not create.',
58
+ ],
59
+ verify: ['`gh api user` with the old token returns 401; the token is gone from /settings/tokens.'],
60
+ docs: [['GitHub: token expiration and revocation', 'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation'], ['GitHub: managing personal access tokens', 'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens']],
41
61
  },
42
62
  cloud: {
43
- order: 3, label: 'Cloud credentials',
44
- 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.'],
45
- url: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#rotating_access_keys_console',
63
+ order: 3, label: 'Cloud and IaC credentials (AWS, GCP, Azure, Terraform, Pulumi)',
64
+ steps: [
65
+ 'AWS: create a new access key, update users of it, deactivate the old key, confirm nothing breaks, then delete it. Better: `aws sso login` (short-lived).',
66
+ 'GCP: `gcloud auth application-default revoke`; delete exposed service-account keys in IAM.',
67
+ 'Azure: `az logout`; revoke sessions for the user in Entra ID.',
68
+ 'Terraform/Pulumi: `terraform logout` / `pulumi logout`, delete the token in HCP Terraform / Pulumi Cloud, log in again. Rotate secrets that sit in local .tfstate files.',
69
+ 'Review CloudTrail / audit logs (including SSM Parameter Store and Secrets Manager reads) from the exposure window.',
70
+ ],
71
+ verify: ['`aws iam list-access-keys` shows the old key as deleted; calls with it return InvalidClientTokenId.'],
72
+ docs: [['AWS: rotate access keys', 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#rotating_access_keys_console'], ['gcloud auth application-default revoke', 'https://cloud.google.com/sdk/gcloud/reference/auth/application-default/revoke']],
46
73
  },
47
74
  kubernetes: {
48
75
  order: 4, label: 'Kubernetes and container registries',
49
- 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".'],
50
- url: 'https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/',
76
+ steps: ['Rotate service-account tokens / client certs referenced by your kubeconfig.', '`docker logout <registry>`, revoke the registry token in its UI, then set "credsStore" so new logins go to the OS keychain.'],
77
+ verify: ['`kubectl auth whoami` with the old credential fails.'],
78
+ docs: [['Kubernetes: service accounts', 'https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/'], ['Docker: credential stores', 'https://docs.docker.com/reference/cli/docker/login/#credential-stores']],
79
+ },
80
+ paas: {
81
+ order: 5, label: 'Deploy platforms (Railway, Vercel, Fly, Render, Heroku, Netlify)', redeploy: true,
82
+ 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.'],
83
+ verify: ['The old DB password / API key is rejected by its provider; the redeployed service is healthy.'],
84
+ docs: [['Railway security disclosure (Oct 21, 2026)', 'https://blog.railway.com/p/security-disclosure-wednesday-october-21st']],
51
85
  },
52
86
  env: {
53
- order: 5, label: 'Secrets present in the environment / CI',
87
+ order: 6, label: 'Secrets present in the environment / CI', redeploy: true,
54
88
  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`.'],
55
- url: 'https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions',
89
+ verify: ['Re-run this tool in CI: the variable no longer appears in the install step.'],
90
+ docs: [['GitHub Actions: using secrets', 'https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions']],
56
91
  },
57
92
  ai: {
58
- order: 6, label: 'AI provider keys and MCP servers',
59
- steps: ['Revoke and reissue the keys in each provider console (OpenAI, Anthropic, etc.).', 'Replace literal values in MCP configs with "${ENV_VAR}" references.'],
60
- url: 'https://blog.trailofbits.com/2025/04/30/insecure-credential-storage-plagues-mcp/',
93
+ order: 7, label: 'AI provider keys and MCP servers',
94
+ steps: ['Revoke and reissue the keys in each provider console (OpenAI, Anthropic, etc.).', 'Replace literal values in MCP configs with "${ENV_VAR}" references; pin MCP server package versions instead of `npx -y <pkg>`.'],
95
+ verify: ['A request with the old key returns 401.'],
96
+ docs: [['Trail of Bits: insecure credential storage in MCP', 'https://blog.trailofbits.com/2025/04/30/insecure-credential-storage-plagues-mcp/']],
61
97
  },
62
98
  ssh: {
63
- order: 7, label: 'SSH keys',
99
+ order: 8, label: 'SSH keys',
64
100
  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.'],
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',
101
+ verify: ['`ssh -i <old key> git@github.com` is denied.'],
102
+ docs: [['GitHub: generating a new SSH key', 'https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent']],
66
103
  },
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',
104
+ dotenv: {
105
+ order: 9, label: 'Application secrets in .env files', redeploy: true,
106
+ steps: ['Rotate database passwords, payment keys and webhook secrets at each provider.', 'Redeploy: most platforms only read env vars at process start.'],
107
+ verify: ['The old value is rejected by its provider.'],
108
+ docs: [['The Twelve-Factor App: config', 'https://12factor.net/config']],
71
109
  },
72
110
  php: {
73
- order: 9, label: 'Patch PHP (PHP2Shell)',
111
+ order: 10, label: 'Patch PHP (PHP2Shell)', redeploy: true,
74
112
  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',
113
+ verify: ['`php -v` in each running container shows a fixed version.'],
114
+ docs: [['CISA alert (Oct 21, 2026)', 'https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching']],
76
115
  },
77
116
  certs: {
78
- order: 10, label: 'Replace certificates under emSign ECC Root CA - G3',
117
+ order: 11, label: 'Replace certificates under emSign ECC Root CA - G3',
79
118
  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
- },
82
- dotenv: {
83
- order: 8, label: 'Application secrets in .env files',
84
- steps: ['Rotate database passwords, payment keys and webhook secrets at each provider.', 'Redeploy: most platforms only read env vars at process start.'],
85
- url: 'https://12factor.net/config',
119
+ verify: ['`openssl s_client -connect host:443` shows the new issuer.'],
120
+ docs: [['Google: compromised eMudhra intermediates', 'https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/']],
86
121
  },
87
122
  };
123
+ for (const r of Object.values(ROTATION)) r.url = r.docs[0][1];
88
124
 
89
125
  export function scan(options = {}) {
90
126
  const ctx = {
@@ -95,6 +131,7 @@ export function scan(options = {}) {
95
131
  iocs: options.iocs ?? loadIocs(options.iocFiles ?? []),
96
132
  exec: options.exec === false ? null : options.exec ?? defaultExec,
97
133
  checkTmp: options.checkTmp ?? options.home === undefined,
134
+ history: options.history ?? null, // {since?: string}
98
135
  };
99
136
  let onlyList = options.only;
100
137
  if (options.incident) {
@@ -110,9 +147,24 @@ export function scan(options = {}) {
110
147
  if ((only && !only.has(name)) || skip.has(name)) continue;
111
148
  findings.push(...fn(ctx));
112
149
  }
150
+ return finish(ctx, options, findings);
151
+ }
152
+
153
+ /** Same as scan(), plus the opt-in OSV lookup (network) when options.osv is set. */
154
+ export async function scanAsync(options = {}) {
155
+ const result = scan(options);
156
+ if (!options.osv) return result;
157
+ const ctx = { home: result.home, projects: result.projects, depth: options.depth ?? 4, iocs: { updated: result.iocsUpdated } };
158
+ const { findings: osv, queried } = await osvMalicious(ctx, options.osvOptions);
159
+ const known = new Set(result.findings.filter((f) => f.id === 'supply.malicious-version').map((f) => f.title.split(' ')[2] + '|' + f.location));
160
+ const findings = osv.filter((f) => !known.has(f.title.split(' ')[2] + '|' + f.location));
161
+ return { ...finish(ctx, options, [...result.findings, ...findings]), osvQueried: queried };
162
+ }
163
+
164
+ function finish(ctx, options, findings) {
113
165
  findings.sort((a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity) || (ROTATION[a.category]?.order ?? 99) - (ROTATION[b.category]?.order ?? 99));
114
166
  const counts = Object.fromEntries(SEVERITY.map((s) => [s, findings.filter((f) => f.severity === s).length]));
115
- return { scannedAt: new Date().toISOString(), home: ctx.home, projects: ctx.projects, iocsUpdated: ctx.iocs.updated, incident: options.incident, counts, findings };
167
+ return { scannedAt: new Date().toISOString(), home: ctx.home, projects: ctx.projects, iocsUpdated: ctx.iocs?.updated ?? options.iocsUpdated, incident: options.incident, counts, findings };
116
168
  }
117
169
 
118
170
  export function runbook(result) {
package/src/scanners.js CHANGED
@@ -54,7 +54,7 @@ export function scanNpmrc(ctx) {
54
54
  (publishable
55
55
  ? 'Any install script can read this and republish every package you maintain (this is how Shai-Hulud spreads). '
56
56
  : 'Any install script can read this registry credential. ') +
57
- 'Prefer short-lived tokens or npm trusted publishing (OIDC) from CI.',
57
+ 'Prefer short-lived tokens or npm trusted publishing (OIDC) from CI.' + tokenHint(value),
58
58
  });
59
59
  }
60
60
  }
@@ -73,6 +73,15 @@ export function scanPypirc(ctx) {
73
73
  }
74
74
 
75
75
  /* ---------------- 2. GitHub ---------------- */
76
+ /** Least-privilege hint from the token's public prefix only (never the value). */
77
+ export function tokenHint(v) {
78
+ if (/^ghp_/.test(v)) return ' This is a classic PAT (ghp_): classic tokens can reach every repo you can; prefer a fine-grained token with an expiry.';
79
+ if (/^gho_/.test(v)) return ' OAuth token (gho_): it stays valid until revoked or unused for a year.';
80
+ if (/^npm_/.test(v)) return ' Check at npmjs.com → Access Tokens whether it is a granular token with an expiry and 2FA bypass off.';
81
+ if (/^(AKIA)/.test(v)) return ' Long-lived IAM user key (AKIA); temporary credentials (ASIA, via SSO/STS) expire on their own.';
82
+ return '';
83
+ }
84
+
76
85
  export function scanGitHub(ctx) {
77
86
  const out = [];
78
87
  const hosts = [join(ctx.home, '.config', 'gh', 'hosts.yml'), join(ctx.home, 'AppData', 'Roaming', 'GitHub CLI', 'hosts.yml')];
@@ -90,7 +99,7 @@ export function scanGitHub(ctx) {
90
99
  title: 'GitHub CLI token stored in plaintext',
91
100
  location: tilde(ctx, f),
92
101
  fingerprint: fingerprint(m[1]),
93
- 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.',
102
+ 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.' + tokenHint(m[1]),
94
103
  });
95
104
  }
96
105
  }
@@ -107,7 +116,7 @@ export function scanGitHub(ctx) {
107
116
  title: `Plaintext git credential for ${m[3]}`,
108
117
  location: tilde(ctx, gitCreds),
109
118
  fingerprint: fingerprint(m[2]),
110
- detail: 'git credential.helper=store keeps passwords/tokens unencrypted. Switch to the OS credential manager.',
119
+ detail: 'git credential.helper=store keeps passwords/tokens unencrypted. Switch to the OS credential manager.' + tokenHint(decodeURIComponent(m[2])),
111
120
  });
112
121
  }
113
122
  }
@@ -236,6 +245,16 @@ function mcpServersFrom(obj, path = []) {
236
245
  return found;
237
246
  }
238
247
 
248
+ export function unpinnedNpx(server) {
249
+ if (typeof server?.command !== 'string' || !/(^|[\\/])(npx|bunx|pnpx)(\.cmd|\.exe)?$/i.test(server.command)) return null;
250
+ const args = (server.args || []).filter((a) => typeof a === 'string');
251
+ const pkg = args.find((a) => !a.startsWith('-'));
252
+ if (!pkg || /^[./]/.test(pkg)) return null;
253
+ const at = pkg.lastIndexOf('@');
254
+ const hasVersion = at > 0 && /^\d/.test(pkg.slice(at + 1));
255
+ return hasVersion ? null : pkg;
256
+ }
257
+
239
258
  function literalSecretsInServer(server) {
240
259
  const hits = [];
241
260
  for (const bag of ['env', 'headers', 'http_headers']) {
@@ -288,6 +307,9 @@ export function scanAiConfigs(ctx) {
288
307
  let json;
289
308
  try { json = JSON.parse(text); } catch { continue; }
290
309
  for (const { name, server } of mcpServersFrom(json)) {
310
+ const pkg = unpinnedNpx(server);
311
+ if (pkg) out.push({ id: 'ai.mcp-unpinned', category: 'ai', severity: 'low', title: `MCP server "${name}" runs an unpinned package (${pkg})`, location: tilde(ctx, f),
312
+ detail: '`npx -y <pkg>` fetches whatever version is latest every time the agent starts, with your agent\'s environment. Pin an exact version (pkg@1.2.3) so a hijacked release is not pulled in automatically.' });
291
313
  for (const hit of literalSecretsInServer(server)) {
292
314
  out.push({
293
315
  id: 'ai.mcp-literal-secret', category: 'ai', severity: 'high',
@@ -377,7 +399,7 @@ export function scanDotenv(ctx) {
377
399
  for (const root of ctx.projects) {
378
400
  for (const f of walk(root, { maxDepth: ctx.depth, skip: SKIP_DIRS })) {
379
401
  const base = f.split(/[\\/]/).pop();
380
- if (!/^\.env(\..+)?$/.test(base) || /\.(example|sample|template|dist|defaults|demo)$/i.test(base)) continue;
402
+ if (!/^\.env(\..+)?$/.test(base) || /\.(example|sample|template|dist|defaults|demo|emulated|emulator|mock)$/i.test(base)) continue;
381
403
  const text = readText(f);
382
404
  if (!text) continue;
383
405
  const secrets = parseDotenv(text).filter(([k, v]) => isSecretPair(k, v));
@@ -409,11 +431,20 @@ export function scanHardening(ctx) {
409
431
  });
410
432
  }
411
433
  const pnpmWs = ctx.projects.map((p) => readText(join(p, 'pnpm-workspace.yaml'))).filter(Boolean).join('\n');
412
- if (!/min-release-age|minimum-release-age/.test(npmrcs) && !/minimumReleaseAge/.test(pnpmWs)) {
434
+ const yarnrc = [join(ctx.home, '.yarnrc.yml'), ...ctx.projects.map((p) => join(p, '.yarnrc.yml'))].map(readText).filter(Boolean).join('\n');
435
+ const bunfig = [join(ctx.home, '.bunfig.toml'), ...ctx.projects.map((p) => join(p, 'bunfig.toml'))].map(readText).filter(Boolean).join('\n');
436
+ const configured = /^\s*min-release-age\s*=/m.test(npmrcs) || /minimumReleaseAge\s*:/.test(pnpmWs) || /npmMinimalAgeGate\s*:/.test(yarnrc) || /^\s*minimumReleaseAge\s*=/m.test(bunfig);
437
+ if (!configured) {
438
+ const has = (f) => ctx.projects.some((p) => exists(join(p, f)));
439
+ const tips = [];
440
+ if (has('package-lock.json') || !(has('pnpm-lock.yaml') || has('yarn.lock') || has('bun.lock'))) tips.push('npm ≥ 11.10: `min-release-age=3` in .npmrc (days)');
441
+ if (has('pnpm-lock.yaml')) tips.push('pnpm ≥ 10.16: `minimumReleaseAge: 4320` in pnpm-workspace.yaml (minutes; pnpm 11 defaults to 1440)');
442
+ if (has('yarn.lock')) tips.push('Yarn Berry: `npmMinimalAgeGate: 4320` in .yarnrc.yml (minutes)');
443
+ if (has('bun.lock')) tips.push('Bun ≥ 1.3: `[install] minimumReleaseAge = 259200` in bunfig.toml (seconds)');
413
444
  out.push({
414
445
  id: 'hardening.release-age', category: 'hardening', severity: 'low',
415
- title: 'No dependency cooldown configured', location: '.npmrc',
416
- 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.',
446
+ title: 'No dependency cooldown configured', location: 'package manager config',
447
+ detail: `Malicious versions are usually pulled within hours. ${tips.join('; ')}. A cooldown would not have caught a compromise that went unnoticed for longer.`,
417
448
  });
418
449
  }
419
450
  return out;
package/src/supply.js CHANGED
@@ -9,10 +9,11 @@ const rel = (root, p) => relative(root, p).replace(/\\/g, '/') || basename(p);
9
9
  const tilde = (ctx, p) => (p.startsWith(ctx.home) ? '~' + p.slice(ctx.home.length).replace(/\\/g, '/') : p);
10
10
 
11
11
  export function loadIocs(extraFiles = []) {
12
- const merged = { updated: BUNDLED.updated, packages: [...BUNDLED.packages], markers: structuredClone(BUNDLED.markers) };
12
+ const merged = { updated: BUNDLED.updated, packages: [...BUNDLED.packages], extensions: [], markers: structuredClone(BUNDLED.markers) };
13
13
  for (const f of extraFiles) {
14
14
  const j = JSON.parse(readFileSync(f, 'utf8'));
15
15
  merged.packages.push(...(j.packages || []));
16
+ merged.extensions.push(...(j.extensions || []));
16
17
  for (const [k, v] of Object.entries(j.markers || {})) merged.markers[k] = [...new Set([...(merged.markers[k] || []), ...v])];
17
18
  if (j.updated && j.updated > merged.updated) merged.updated = j.updated;
18
19
  }
package/src/util.js CHANGED
@@ -72,6 +72,7 @@ const SECRET_NAME = /(^|_|-)(api[-_]?key|apikey|token|secret|password|passwd|pwd
72
72
  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;
73
73
 
74
74
  export function looksLikeSecretName(name) {
75
+ if (/^(OLD)?PWD$/i.test(name)) return false; // shell's working directory, not a password
75
76
  return SECRET_NAME.test(name) && !NOT_SECRET_NAME.test(name);
76
77
  }
77
78