secret-blast-radius 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -7
- package/action.yml +6 -2
- package/bin/cli.js +12 -6
- package/package.json +9 -3
- package/src/extension-iocs.json +911 -0
- package/src/format.js +5 -1
- package/src/incidents.js +14 -3
- package/src/index.js +90 -38
- package/src/scanners.js +38 -7
- package/src/supply.js +2 -1
- package/src/util.js +1 -0
- package/src/v3.js +240 -0
package/src/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}`), ''
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
'
|
|
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
|
-
|
|
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: '
|
|
34
|
-
steps: [
|
|
35
|
-
|
|
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: [
|
|
40
|
-
|
|
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: [
|
|
45
|
-
|
|
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
|
|
50
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
order:
|
|
69
|
-
steps: ['Rotate
|
|
70
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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: '
|
|
416
|
-
detail:
|
|
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
|
|