@vulnify/cli 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 +32 -2
- package/bin/vulnify-cli.mjs +34 -0
- package/hook/vulnify-hook.mjs +43 -7
- package/lib/audit.mjs +446 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
# @vulnify/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Audit the AI agents on your machine
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @vulnify/cli audit # this machine + the current project
|
|
7
|
+
npx @vulnify/cli audit --json # machine-readable
|
|
8
|
+
npx @vulnify/cli audit --fail-on high # exit 1 in CI if anything is high or critical
|
|
9
|
+
npx @vulnify/cli audit --dir path/to/repo # check another project folder
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Local and read-only: it reads agent config files, never changes them, never starts an MCP server and makes no network calls. Secrets are always masked (first 4 characters + `***`), in text and in `--json`.
|
|
13
|
+
|
|
14
|
+
It checks Claude Code (`~/.claude/settings.json`, `.claude/settings*.json`, `~/.claude.json`, `.mcp.json`, `CLAUDE.md`, managed settings), Cursor (`~/.cursor/mcp.json`, `.cursor/mcp.json`, `~/.cursor/cli-config.json`, `.cursorrules`, `.cursor/rules/`), Codex (`~/.codex/config.toml`, `AGENTS.md`), Claude Desktop (`claude_desktop_config.json` on macOS, Windows and Linux) and VS Code / Copilot (`.vscode/mcp.json`, user `mcp.json`, `~/.copilot/mcp-config.json`, `.github/copilot-instructions.md`). `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `COPILOT_HOME` are honored.
|
|
15
|
+
|
|
16
|
+
| Rule | Severity |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| Approvals bypassed (`bypassPermissions`, Codex `danger-full-access`) or `Bash(*)` / wildcard allow | critical |
|
|
19
|
+
| Destructive command pre-approved (`rm`, `curl \| sh`, `git push --force`, `chmod`, `sudo`) | high |
|
|
20
|
+
| Plaintext secret in MCP `env`, `headers`, `args` or URL | high |
|
|
21
|
+
| Token (AWS, GitHub, OpenAI, Anthropic, Slack, Stripe, ...) in an agent config file | high |
|
|
22
|
+
| MCP server exposing shell / command execution | high |
|
|
23
|
+
| Filesystem MCP server with `/` or home access | high |
|
|
24
|
+
| Prompt injection in instruction files: "ignore the rules", "don't tell the user", exfiltration URLs, invisible Unicode, hidden HTML comments | high |
|
|
25
|
+
| Remote HTTP MCP server with no auth | medium |
|
|
26
|
+
| No Claude Code deny rules for `.env`, `~/.ssh`, `~/.aws` | medium |
|
|
27
|
+
| `.env` in the project | medium |
|
|
28
|
+
| Agent config with secrets readable by other users (POSIX) | medium |
|
|
29
|
+
| `npx` / `uvx` MCP server with no pinned version | low |
|
|
30
|
+
|
|
31
|
+
Each finding shows severity, `file:line`, why it matters and how to fix it, followed by a score out of 100.
|
|
32
|
+
|
|
33
|
+
## Install the Vulnify hook into Claude Code
|
|
4
34
|
|
|
5
35
|
```bash
|
|
6
36
|
npx @vulnify/cli hook claude-code # this project (.claude/)
|
|
@@ -12,4 +42,4 @@ The API key comes from `VULNIFY_API_KEY`, or a hidden prompt. It is never printe
|
|
|
12
42
|
|
|
13
43
|
Before each shell command, file write, web fetch or MCP tool call, Claude Code asks Vulnify. ALLOW runs, REVIEW waits for a person, BLOCK stops it, and if Vulnify can't be reached the call is held.
|
|
14
44
|
|
|
15
|
-
Docs: https://docs.vulnify.io/mcp/claude-code-hook/
|
|
45
|
+
Docs: https://docs.vulnify.io/mcp/audit/ · https://docs.vulnify.io/mcp/claude-code-hook/
|
package/bin/vulnify-cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* npx @vulnify/cli hook claude-code [--user] [--uninstall] [--yes]
|
|
4
|
+
* npx @vulnify/cli audit [--json] [--fail-on <severity>] [--dir <path>]
|
|
4
5
|
*
|
|
5
6
|
* Installs the fail-closed Vulnify PreToolUse hook into Claude Code settings.
|
|
6
7
|
* The API key comes from VULNIFY_API_KEY or a hidden prompt, and is never printed.
|
|
@@ -10,6 +11,7 @@ import { homedir } from 'node:os';
|
|
|
10
11
|
import { dirname, join, resolve } from 'node:path';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
12
13
|
import { createInterface } from 'node:readline';
|
|
14
|
+
import { audit, format, shouldFail, SEVERITIES, CTA } from '../lib/audit.mjs';
|
|
13
15
|
|
|
14
16
|
const HOOK_FILE = 'vulnify-hook.mjs';
|
|
15
17
|
const MATCHER = 'Bash|PowerShell|Write|Edit|MultiEdit|NotebookEdit|WebFetch|mcp__.*';
|
|
@@ -162,7 +164,38 @@ export function uninstall({ user, cwd = process.cwd(), home = homedir(), log = c
|
|
|
162
164
|
log(removed || hadFile || keyRemoved ? `Removed the Vulnify hook (${user ? 'user' : 'project'} scope)${keyRemoved ? ' and the saved API key' : ''}.` : 'No Vulnify hook found here.');
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
const AUDIT_HELP = `Usage: npx @vulnify/cli audit [options]
|
|
168
|
+
|
|
169
|
+
Read-only audit of the AI agents on this machine and in this project
|
|
170
|
+
(Claude Code, Cursor, Codex, Claude Desktop, VS Code). No network calls;
|
|
171
|
+
secrets are always masked.
|
|
172
|
+
|
|
173
|
+
Options:
|
|
174
|
+
--json machine-readable output
|
|
175
|
+
--fail-on <severity> exit 1 if any finding is at or above: ${SEVERITIES.join(', ')}
|
|
176
|
+
--dir <path> project folder to check (default: current folder)
|
|
177
|
+
-h, --help show this help`;
|
|
178
|
+
|
|
179
|
+
export function runAudit(argv, { log = console.log, err = console.error, home, cwd = process.cwd(), env = process.env, platform = process.platform, color = !!process.stdout.isTTY && !env.NO_COLOR } = {}) {
|
|
180
|
+
let json = false, failOn = null, dir = cwd;
|
|
181
|
+
for (let i = 0; i < argv.length; i++) {
|
|
182
|
+
const a = argv[i];
|
|
183
|
+
if (a === '-h' || a === '--help') { log(AUDIT_HELP); return 0; }
|
|
184
|
+
else if (a === '--json') json = true;
|
|
185
|
+
else if (a === '--fail-on' || a.startsWith('--fail-on=')) failOn = (a.includes('=') ? a.split('=')[1] : argv[++i] || '').toLowerCase();
|
|
186
|
+
else if (a === '--dir' || a.startsWith('--dir=')) dir = resolve(cwd, a.includes('=') ? a.split('=')[1] : argv[++i] || '.');
|
|
187
|
+
else { err(`Unknown option: ${a}\n\n${AUDIT_HELP}`); return 2; }
|
|
188
|
+
}
|
|
189
|
+
if (failOn !== null && !SEVERITIES.includes(failOn)) { err(`--fail-on must be one of: ${SEVERITIES.join(', ')}`); return 2; }
|
|
190
|
+
const h = home || homedir();
|
|
191
|
+
const result = audit({ home: h, cwd: dir, env, platform });
|
|
192
|
+
if (json) log(JSON.stringify({ ...result, cta: CTA }, null, 2));
|
|
193
|
+
else log(format(result, { home: h, cwd: dir, color }));
|
|
194
|
+
return shouldFail(result, failOn) ? 1 : 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
165
197
|
const HELP = `Usage: npx @vulnify/cli hook claude-code [options]
|
|
198
|
+
npx @vulnify/cli audit [--json] [--fail-on <severity>]
|
|
166
199
|
|
|
167
200
|
Installs the fail-closed Vulnify PreToolUse hook into Claude Code.
|
|
168
201
|
|
|
@@ -175,6 +208,7 @@ Options:
|
|
|
175
208
|
Docs: ${DOCS}`;
|
|
176
209
|
|
|
177
210
|
export async function main(argv = process.argv.slice(2)) {
|
|
211
|
+
if (argv[0] === 'audit') return runAudit(argv.slice(1));
|
|
178
212
|
const flags = new Set(argv.filter((a) => a.startsWith('-')));
|
|
179
213
|
const words = argv.filter((a) => !a.startsWith('-'));
|
|
180
214
|
if (flags.has('-h') || flags.has('--help') || words[0] !== 'hook' || words[1] !== 'claude-code') {
|
package/hook/vulnify-hook.mjs
CHANGED
|
@@ -53,6 +53,33 @@ function bashParts(command) {
|
|
|
53
53
|
|
|
54
54
|
const MAX_RECORDS = 1_000_000;
|
|
55
55
|
const BIG = 10_000; // "the whole table": DELETE without WHERE, DROP, TRUNCATE
|
|
56
|
+
const FS_TREE = 5_000; // a recursive or wildcard filesystem delete: a whole directory tree
|
|
57
|
+
|
|
58
|
+
/** Files that grant an AI agent permissions, tools or MCP servers. */
|
|
59
|
+
export const AGENT_CONFIG_PATH = /(^|[\\/\s'"=])(\.claude[\\/](settings(\.local)?\.json|commands|agents|hooks)|claude_desktop_config\.json|\.mcp\.json|mcp\.json|\.cursor[\\/](mcp\.json|cli\.json|permissions\.json|rules)|\.vscode[\\/](mcp|settings)\.json|\.codex[\\/]config\.toml|\.gemini[\\/]settings\.json|\.codeium[\\/]windsurf[\\/]mcp_config\.json|\.continue[\\/]config\.(json|ya?ml)|\.aider\.conf\.yml)/i;
|
|
60
|
+
|
|
61
|
+
/** A recursive or wildcard filesystem delete (rm -rf, rm *, find -delete, find -exec rm, shred -r, git clean -fdx...). */
|
|
62
|
+
export function isTreeDelete(c) {
|
|
63
|
+
for (const part of bashParts(c)) {
|
|
64
|
+
const words = part.replace(/^((sudo|doas|nice|nohup|time|command|exec)\s+(-\S+\s+)*)+/, '').split(/\s+/);
|
|
65
|
+
const cmd = words[0].replace(/^.*\//, '');
|
|
66
|
+
const args = words.slice(1).filter((w) => w && !w.startsWith('-'));
|
|
67
|
+
if (['rm', 'shred', 'unlink', 'rmdir', 'Remove-Item', 'del', 'rd'].includes(cmd)) {
|
|
68
|
+
if (words.some((w) => /^-\w*[rR]/.test(w) || /^--recursive$/.test(w) || /^-Recurse$/i.test(w) || /^\/s$/i.test(w))) return true;
|
|
69
|
+
if (args.some((a) => /[*?[]/.test(a) || a === '/' || a === '~' || a === '.' || a === '..')) return true;
|
|
70
|
+
}
|
|
71
|
+
if (cmd === 'find' && (words.includes('-delete') || /-exec(dir)?\s+(\S*\/)?(rm|shred|unlink)\b/.test(part))) return true;
|
|
72
|
+
if (cmd === 'xargs' && /\b(rm|shred|unlink)\b/.test(part)) return true;
|
|
73
|
+
if (cmd === 'git' && /^clean\b/.test(words.slice(1).join(' ')) && words.some((w) => /^-\w*f/.test(w))) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A shell command that writes an agent permission/config file (redirect, tee, sed -i, cp/mv onto it, jq > ...). */
|
|
79
|
+
export function writesAgentConfig(c) {
|
|
80
|
+
if (!AGENT_CONFIG_PATH.test(c)) return false;
|
|
81
|
+
return /(>>?|\btee\b|\bsed\s+-i|\bperl\s+-pi|\b(cp|mv|install|ln)\b|\btouch\b|\bcat\s*<<|Set-Content|Out-File|Add-Content)/.test(c);
|
|
82
|
+
}
|
|
56
83
|
const num = (v) => {
|
|
57
84
|
const n = Number(String(v).replace(/[_,]/g, ''));
|
|
58
85
|
return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), MAX_RECORDS) : 0;
|
|
@@ -70,6 +97,7 @@ export function estimateBashRecords(c) {
|
|
|
70
97
|
const del = c.match(/\b(delete\s+from|update)\s+[\w."]+([^;'"]*)/i);
|
|
71
98
|
if (del && !/\bwhere\b/i.test(del[2] || '') && !sql) counts.push(BIG);
|
|
72
99
|
for (const m of c.matchAll(/(?:--(?:limit|count|max-results|max-count|page-size|per-page|batch-size|records|rows|number)|(?:^|\s)-n)[=\s]+(\d[\d_,]*)/gi)) counts.push(num(m[1]));
|
|
100
|
+
if (isTreeDelete(c)) counts.push(FS_TREE);
|
|
73
101
|
for (const m of c.matchAll(/\bseq\s+(?:\d+\s+)?(\d+)/g)) counts.push(num(m[1]));
|
|
74
102
|
for (const m of c.matchAll(/\{\d+\.\.(\d+)\}/g)) counts.push(num(m[1]));
|
|
75
103
|
for (const part of bashParts(c)) {
|
|
@@ -79,7 +107,7 @@ export function estimateBashRecords(c) {
|
|
|
79
107
|
if (['rm', 'shred', 'unlink', 'rmdir'].includes(cmd)) {
|
|
80
108
|
const recursive = words.some((w) => /^-\w*[rR]/.test(w) || w === '--recursive');
|
|
81
109
|
const glob = args.some((a) => /[*?[]/.test(a));
|
|
82
|
-
counts.push(Math.max(args.length, recursive || glob ?
|
|
110
|
+
counts.push(Math.max(args.length, recursive || glob ? FS_TREE : 0));
|
|
83
111
|
}
|
|
84
112
|
if (['scp', 'rsync', 'sftp'].includes(cmd) && args.length > 1) {
|
|
85
113
|
const recursive = words.some((w) => /^-\w*[ra]/.test(w));
|
|
@@ -104,11 +132,12 @@ function isEmailCommand(c) {
|
|
|
104
132
|
|
|
105
133
|
function classifyBash(command) {
|
|
106
134
|
const c = command;
|
|
107
|
-
if (/(^|[\s;&|(])(rm|rmdir|shred|unlink)\s/.test(c) || /\b(drop\s+(table|database|schema)|truncate\s+table|delete\s+from)\b/i.test(c)
|
|
135
|
+
if (isTreeDelete(c) || /(^|[\s;&|(])(rm|rmdir|shred|unlink)\s/.test(c) || /\b(drop\s+(table|database|schema)|truncate\s+table|delete\s+from)\b/i.test(c)
|
|
108
136
|
|| /git\s+(push\s+.*(--force|-f\b)|reset\s+--hard|clean\s+-\w*f|branch\s+-D)/.test(c)
|
|
109
137
|
|| /\b(curl|http|httpie|xh)\b.*\s-X\s*DELETE\b/i.test(c) || /\baws\s+s3\s+(rm|rb)\b|\bgsutil\s+rm\b|\bkubectl\s+delete\b/.test(c)) {
|
|
110
138
|
return { action: 'DELETE_DATA', destination: /\b(curl|http|httpie|xh|aws|gsutil|kubectl)\b/.test(c) && !/(^|[\s;&|(])rm\s/.test(c) ? 'EXTERNAL_API' : 'INTERNAL' };
|
|
111
139
|
}
|
|
140
|
+
if (writesAgentConfig(c)) return { action: 'WRITE_DATA', destination: 'INTERNAL' };
|
|
112
141
|
if (isEmailCommand(c)) return { action: 'SEND_EMAIL', destination: 'EXTERNAL_EMAIL' };
|
|
113
142
|
if (/\b(curl|wget|http|httpie|xh)\b.*(\s-X\s*(POST|PUT|PATCH)|\s(-d|--data\S*|-F|--form\S*|-T|--upload-file|--json|--post-file|--post-data|--body-file)\b|\s\S+=@)/i.test(c)
|
|
114
143
|
|| /\b(scp|rsync|sftp|ftp|lftp|nc|ncat|netcat|socat)\b/.test(c) || /git\s+push\b/.test(c) || /\b(npm|pnpm|yarn)\s+publish\b|\btwine\s+upload\b|\bdocker\s+push\b/.test(c)
|
|
@@ -193,10 +222,16 @@ export function describe(input, cfg = config()) {
|
|
|
193
222
|
if (name === 'Bash' || name === 'PowerShell') {
|
|
194
223
|
const command = String(ti.command || '');
|
|
195
224
|
ev = { ...classifyBash(command), resource: 'shell', records: estimateBashRecords(command) };
|
|
196
|
-
|
|
225
|
+
const hints = [];
|
|
226
|
+
if (isTreeDelete(command)) hints.push('recursive or wildcard filesystem delete');
|
|
227
|
+
if (writesAgentConfig(command)) { hints.push('writes an agent permission/config file'); ev.agentConfig = true; }
|
|
228
|
+
content = hints.length ? `# vulnify-hint: ${hints.join('; ')}\n${command}` : command;
|
|
197
229
|
} else if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(name)) {
|
|
198
|
-
|
|
199
|
-
|
|
230
|
+
const path = String(ti.file_path ?? ti.notebook_path ?? ti.path ?? '');
|
|
231
|
+
ev = { action: 'WRITE_DATA', destination: 'INTERNAL', resource: 'local files', agentConfig: AGENT_CONFIG_PATH.test(path) };
|
|
232
|
+
const edits = Array.isArray(ti.edits) ? ti.edits.map((e) => String(e?.new_string ?? '')).join('\n') : '';
|
|
233
|
+
const body = String(ti.content ?? ti.new_string ?? ti.new_source ?? '') || edits;
|
|
234
|
+
content = path ? `file_path: ${path}\n${body}` : body;
|
|
200
235
|
} else if (name === 'WebFetch') {
|
|
201
236
|
ev = { action: 'READ_DATA', destination: 'EXTERNAL_API', resource: 'web' };
|
|
202
237
|
content = String(ti.url || '');
|
|
@@ -209,8 +244,9 @@ export function describe(input, cfg = config()) {
|
|
|
209
244
|
}
|
|
210
245
|
if (ev.action === 'READ_DATA' && !cfg.checkReads && name !== 'WebFetch') return null;
|
|
211
246
|
const body = { agent: cfg.agent, action: ev.action, resource: ev.resource, destination: ev.destination, recordsAffected: ev.records || 1 };
|
|
212
|
-
|
|
213
|
-
|
|
247
|
+
// Agent config edits always carry path + content: the engine needs them to flag "Agent permission change".
|
|
248
|
+
const risky = ['SEND_EMAIL', 'EXPORT_DATA', 'DELETE_DATA'].includes(ev.action) || name === 'WebFetch' || !!ev.agentConfig;
|
|
249
|
+
if (content && (cfg.sendContent === 'always' || (cfg.sendContent === 'risky' && risky) || (cfg.sendContent !== 'never' && ev.agentConfig))) body.content = clip(content, 20000);
|
|
214
250
|
return body;
|
|
215
251
|
}
|
|
216
252
|
|
package/lib/audit.mjs
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vulnify audit: a local, read-only audit of the AI agents configured on this machine
|
|
3
|
+
* and in the current project. It reads config files, never writes, never runs a server,
|
|
4
|
+
* and makes no network calls. Secrets are always masked (first 4 characters + ***).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { basename, join, posix, relative, resolve, sep, win32 } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const SEVERITIES = ['low', 'medium', 'high', 'critical'];
|
|
11
|
+
const WEIGHT = { critical: 25, high: 15, medium: 7, low: 3 };
|
|
12
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
export const mask = (s) => `${String(s).slice(0, 4)}***`;
|
|
15
|
+
|
|
16
|
+
// ---------- secret patterns ----------
|
|
17
|
+
const TOKEN_PATTERNS = [
|
|
18
|
+
['AWS access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
19
|
+
['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
|
|
20
|
+
['Anthropic key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
|
|
21
|
+
['OpenAI key', /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/],
|
|
22
|
+
['Slack token', /\bxox[abposr]-[A-Za-z0-9-]{10,}/],
|
|
23
|
+
['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
24
|
+
['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,}/],
|
|
25
|
+
['Stripe key', /\b[sr]k_live_[A-Za-z0-9]{20,}/],
|
|
26
|
+
['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
|
|
27
|
+
['Vulnify key', /\bvln_(?:live|test)_[A-Za-z0-9_]{12,}/],
|
|
28
|
+
['Private key', /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/],
|
|
29
|
+
];
|
|
30
|
+
export function findToken(text) {
|
|
31
|
+
for (const [name, re] of TOKEN_PATTERNS) {
|
|
32
|
+
const m = re.exec(String(text));
|
|
33
|
+
if (m) return { name, value: m[0] };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const SECRET_NAME = /(?:^|_|-)(?:API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PAT|AUTH|CREDENTIALS?|PRIVATE)(?:$|_|-)/i;
|
|
38
|
+
const isReference = (v) => /^\$\{?[A-Za-z_][\w:.-]*\}?$|^\$\{(?:env|input):[^}]+\}$|^<.*>$|^your[-_ ]|^xxx|^\*+$|^(?:changeme|placeholder|todo|none|null|true|false)$/i.test(String(v).trim());
|
|
39
|
+
|
|
40
|
+
// ---------- tiny TOML reader (enough for ~/.codex/config.toml) ----------
|
|
41
|
+
export function parseToml(text) {
|
|
42
|
+
const root = {};
|
|
43
|
+
let cur = root;
|
|
44
|
+
const setPath = (obj, keys) => keys.reduce((o, k) => (o[k] ??= {}), obj);
|
|
45
|
+
const splitKey = (k) => (k.match(/"[^"]*"|'[^']*'|[^.\s]+/g) || []).map((p) => p.replace(/^["']|["']$/g, ''));
|
|
46
|
+
const lines = text.split(/\r?\n/);
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
let line = lines[i].trim();
|
|
49
|
+
if (!line || line.startsWith('#')) continue;
|
|
50
|
+
const t = line.match(/^\[\[?\s*([^\]]+?)\s*\]\]?$/);
|
|
51
|
+
if (t) { cur = setPath(root, splitKey(t[1])); continue; }
|
|
52
|
+
const eq = line.indexOf('=');
|
|
53
|
+
if (eq < 0) continue;
|
|
54
|
+
const key = line.slice(0, eq).trim();
|
|
55
|
+
let raw = line.slice(eq + 1).trim();
|
|
56
|
+
// Multi-line arrays / triple-quoted strings
|
|
57
|
+
const opens = (s) => (s.match(/\[/g) || []).length - (s.match(/\]/g) || []).length;
|
|
58
|
+
if (raw.startsWith('[')) { while (opens(stripStrings(raw)) > 0 && i + 1 < lines.length) raw += '\n' + lines[++i]; }
|
|
59
|
+
else if (/^("""|''')/.test(raw)) { const q = raw.slice(0, 3); while (!raw.slice(3).includes(q) && i + 1 < lines.length) raw += '\n' + lines[++i]; }
|
|
60
|
+
const keys = splitKey(key);
|
|
61
|
+
setPath(cur, keys.slice(0, -1))[keys.at(-1)] = tomlValue(raw);
|
|
62
|
+
}
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
function stripStrings(s) { return s.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '""').replace(/#.*$/gm, ''); }
|
|
66
|
+
function tomlValue(raw) {
|
|
67
|
+
raw = raw.trim();
|
|
68
|
+
if (/^("""|''')/.test(raw)) return raw.slice(3, raw.lastIndexOf(raw.slice(0, 3)));
|
|
69
|
+
if (raw.startsWith('"')) { const m = raw.match(/^"((?:\\.|[^"\\])*)"/); try { return JSON.parse(`"${m ? m[1] : ''}"`); } catch { return m ? m[1] : ''; } }
|
|
70
|
+
if (raw.startsWith("'")) { const m = raw.match(/^'([^']*)'/); return m ? m[1] : ''; }
|
|
71
|
+
if (raw.startsWith('[')) return splitTop(raw.slice(1, raw.lastIndexOf(']'))).map(tomlValue);
|
|
72
|
+
if (raw.startsWith('{')) {
|
|
73
|
+
const o = {};
|
|
74
|
+
for (const part of splitTop(raw.slice(1, raw.lastIndexOf('}')))) {
|
|
75
|
+
const eq = part.indexOf('=');
|
|
76
|
+
if (eq > 0) o[part.slice(0, eq).trim().replace(/^["']|["']$/g, '')] = tomlValue(part.slice(eq + 1));
|
|
77
|
+
}
|
|
78
|
+
return o;
|
|
79
|
+
}
|
|
80
|
+
const v = raw.replace(/\s+#.*$/, '');
|
|
81
|
+
if (v === 'true') return true;
|
|
82
|
+
if (v === 'false') return false;
|
|
83
|
+
return /^[+-]?\d+(\.\d+)?$/.test(v) ? Number(v) : v;
|
|
84
|
+
}
|
|
85
|
+
function splitTop(s) {
|
|
86
|
+
const out = []; let depth = 0, q = null, buf = '';
|
|
87
|
+
for (let i = 0; i < s.length; i++) {
|
|
88
|
+
const c = s[i];
|
|
89
|
+
if (q) { buf += c; if (c === '\\' && q === '"') { buf += s[++i] ?? ''; continue; } if (c === q) q = null; continue; }
|
|
90
|
+
if (c === '"' || c === "'") { q = c; buf += c; continue; }
|
|
91
|
+
if (c === '#') { while (i < s.length && s[i] !== '\n') i++; continue; }
|
|
92
|
+
if (c === '[' || c === '{') depth++;
|
|
93
|
+
if (c === ']' || c === '}') depth--;
|
|
94
|
+
if (c === ',' && depth === 0) { if (buf.trim()) out.push(buf.trim()); buf = ''; continue; }
|
|
95
|
+
buf += c;
|
|
96
|
+
}
|
|
97
|
+
if (buf.trim()) out.push(buf.trim());
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------- discovery ----------
|
|
102
|
+
/** Every config location we know, per tool. Paths confirmed against each tool's docs (Oct 2026). */
|
|
103
|
+
export function locations({ home = homedir(), cwd = process.cwd(), platform = process.platform, env = process.env } = {}) {
|
|
104
|
+
// Build paths for the target OS, not the host (so a simulated win32/darwin run is correct anywhere).
|
|
105
|
+
const join = (platform === 'win32' ? win32 : posix).join;
|
|
106
|
+
const claudeHome = env.CLAUDE_CONFIG_DIR || join(home, '.claude');
|
|
107
|
+
const codexHome = env.CODEX_HOME || join(home, '.codex');
|
|
108
|
+
const appData = env.APPDATA || join(home, 'AppData', 'Roaming');
|
|
109
|
+
const desktop = platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Claude')
|
|
110
|
+
: platform === 'win32' ? join(appData, 'Claude') : join(env.XDG_CONFIG_HOME || join(home, '.config'), 'Claude');
|
|
111
|
+
const vscodeUser = platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Code', 'User')
|
|
112
|
+
: platform === 'win32' ? join(appData, 'Code', 'User') : join(env.XDG_CONFIG_HOME || join(home, '.config'), 'Code', 'User');
|
|
113
|
+
const managed = platform === 'darwin' ? '/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
114
|
+
: platform === 'win32' ? 'C:\\Program Files\\ClaudeCode\\managed-settings.json' : '/etc/claude-code/managed-settings.json';
|
|
115
|
+
const p = (rel) => join(cwd, rel);
|
|
116
|
+
return [
|
|
117
|
+
// Claude Code
|
|
118
|
+
{ tool: 'Claude Code', kind: 'claude-settings', scope: 'user', path: join(claudeHome, 'settings.json') },
|
|
119
|
+
{ tool: 'Claude Code', kind: 'claude-settings', scope: 'managed', path: managed },
|
|
120
|
+
{ tool: 'Claude Code', kind: 'claude-settings', scope: 'project', path: p('.claude/settings.json') },
|
|
121
|
+
{ tool: 'Claude Code', kind: 'claude-settings', scope: 'project', path: p('.claude/settings.local.json') },
|
|
122
|
+
{ tool: 'Claude Code', kind: 'claude-json', scope: 'user', path: join(home, '.claude.json') },
|
|
123
|
+
{ tool: 'Claude Code', kind: 'mcp', scope: 'project', path: p('.mcp.json') },
|
|
124
|
+
{ tool: 'Claude Code', kind: 'instructions', scope: 'user', path: join(claudeHome, 'CLAUDE.md') },
|
|
125
|
+
{ tool: 'Claude Code', kind: 'instructions', scope: 'project', path: p('CLAUDE.md') },
|
|
126
|
+
{ tool: 'Claude Code', kind: 'instructions', scope: 'project', path: p('CLAUDE.local.md') },
|
|
127
|
+
{ tool: 'Claude Code', kind: 'instructions', scope: 'project', path: p('.claude/CLAUDE.md') },
|
|
128
|
+
// Cursor
|
|
129
|
+
{ tool: 'Cursor', kind: 'mcp', scope: 'user', path: join(home, '.cursor', 'mcp.json') },
|
|
130
|
+
{ tool: 'Cursor', kind: 'mcp', scope: 'project', path: p('.cursor/mcp.json') },
|
|
131
|
+
{ tool: 'Cursor', kind: 'cursor-cli', scope: 'user', path: join(home, '.cursor', 'cli-config.json') },
|
|
132
|
+
{ tool: 'Cursor', kind: 'instructions', scope: 'project', path: p('.cursorrules') },
|
|
133
|
+
{ tool: 'Cursor', kind: 'instructions-dir', scope: 'project', path: p('.cursor/rules') },
|
|
134
|
+
// Codex
|
|
135
|
+
{ tool: 'Codex', kind: 'codex', scope: 'user', path: join(codexHome, 'config.toml') },
|
|
136
|
+
{ tool: 'Codex', kind: 'codex', scope: 'project', path: p('.codex/config.toml') },
|
|
137
|
+
{ tool: 'Codex', kind: 'instructions', scope: 'user', path: join(codexHome, 'AGENTS.md') },
|
|
138
|
+
{ tool: 'Codex', kind: 'instructions', scope: 'project', path: p('AGENTS.md') },
|
|
139
|
+
// Claude Desktop
|
|
140
|
+
{ tool: 'Claude Desktop', kind: 'mcp', scope: 'user', path: join(desktop, 'claude_desktop_config.json') },
|
|
141
|
+
// VS Code / Copilot
|
|
142
|
+
{ tool: 'VS Code', kind: 'mcp', scope: 'project', path: p('.vscode/mcp.json') },
|
|
143
|
+
{ tool: 'VS Code', kind: 'mcp', scope: 'user', path: join(vscodeUser, 'mcp.json') },
|
|
144
|
+
{ tool: 'VS Code', kind: 'mcp', scope: 'user', path: join(env.COPILOT_HOME || join(home, '.copilot'), 'mcp-config.json') },
|
|
145
|
+
{ tool: 'VS Code', kind: 'instructions', scope: 'project', path: p('.github/copilot-instructions.md') },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readText(path) {
|
|
150
|
+
try {
|
|
151
|
+
const st = statSync(path);
|
|
152
|
+
if (!st.isFile() || st.size > MAX_BYTES) return null;
|
|
153
|
+
return { text: readFileSync(path, 'utf8'), mode: st.mode };
|
|
154
|
+
} catch { return null; }
|
|
155
|
+
}
|
|
156
|
+
const parseJsonLoose = (t) => {
|
|
157
|
+
try { return JSON.parse(t); } catch { /* JSONC: drop comments and trailing commas */ }
|
|
158
|
+
try { return JSON.parse(t.replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, s) => s ?? '').replace(/,(\s*[}\]])/g, '$1')); } catch { return undefined; }
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** 1-based line of the first needle, then each following needle after it. */
|
|
162
|
+
export function lineOf(text, ...needles) {
|
|
163
|
+
let at = 0, found = -1;
|
|
164
|
+
for (const n of needles.filter(Boolean)) {
|
|
165
|
+
const i = text.indexOf(n, at);
|
|
166
|
+
if (i < 0) break;
|
|
167
|
+
found = i; at = i + n.length;
|
|
168
|
+
}
|
|
169
|
+
return found < 0 ? 1 : text.slice(0, found).split('\n').length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------- rules ----------
|
|
173
|
+
const FIX_CTA = 'Vulnify can hold this at runtime: npx @vulnify/cli hook claude-code';
|
|
174
|
+
const R = {
|
|
175
|
+
'mcp-remote-no-auth': { severity: 'medium', title: 'Remote MCP server with no auth', why: 'Anyone who knows the URL can use it, and you cannot tell who is on the other side or revoke access.', fix: 'Use an MCP server that requires OAuth or a bearer token (headers / bearer_token_env_var), or remove it.' },
|
|
176
|
+
'mcp-unpinned': { severity: 'low', title: 'MCP server runs an unpinned package', why: 'npx/uvx downloads the latest version on every start, so a compromised release runs on your machine with no review (supply chain).', fix: 'Pin a version, e.g. "@scope/server@1.2.3" or "pkg==1.2.3", and update on purpose.' },
|
|
177
|
+
'mcp-fs-broad': { severity: 'high', title: 'Filesystem MCP server can reach your whole disk or home', why: 'The agent can read ~/.ssh, ~/.aws, browser profiles and every repo, and can overwrite them.', fix: 'Limit it to the project folder (e.g. "${workspaceFolder}" or "."), never "/" or "~".' },
|
|
178
|
+
'mcp-shell': { severity: 'high', title: 'MCP server exposes shell / command execution', why: 'Any prompt injection that reaches the agent can run arbitrary commands, outside the agent\'s own permission prompts.', fix: 'Remove it or gate it with per-call approval. ' + FIX_CTA },
|
|
179
|
+
'mcp-plaintext-secret': { severity: 'high', title: 'Plaintext secret in MCP server config', why: 'The key sits in a config file that gets synced, backed up, pasted into issues and read by other tools and agents.', fix: 'Move it to an env var or OS keychain and reference it (${env:NAME}, ${input:...}, bearer_token_env_var), then rotate the key.' },
|
|
180
|
+
'perm-bypass': { severity: 'critical', title: 'Approvals are bypassed or every command is allowed', why: 'The agent runs any shell command and edits any file without asking, so one bad prompt is enough to do damage.', fix: 'Remove bypass mode / wildcard allow rules and allow only the specific commands you need. ' + FIX_CTA },
|
|
181
|
+
'perm-dangerous-allow': { severity: 'high', title: 'Destructive command pre-approved', why: 'rm, curl|sh, git push --force, chmod and sudo can destroy work or open the machine, and they run with no prompt.', fix: 'Delete the allow rule (or move it to "ask"), so a person confirms each time.' },
|
|
182
|
+
'perm-no-deny-secrets': { severity: 'medium', title: 'No deny rules for secret files', why: 'Nothing stops the agent from reading .env, ~/.ssh or ~/.aws and putting them into a prompt, a commit or a web request.', fix: 'Add to permissions.deny: "Read(./.env)", "Read(./.env.*)", "Read(~/.ssh/**)", "Read(~/.aws/**)".' },
|
|
183
|
+
'project-dotenv': { severity: 'medium', title: '.env file in the project', why: 'Agents working here can read it unless denied, and it ends up in context, logs and generated code.', fix: 'Keep it git-ignored, deny Read(./.env*) for agents, and prefer a secrets manager for real credentials.' },
|
|
184
|
+
'config-token': { severity: 'high', title: 'Token in an agent config file', why: 'Agent configs are often committed, synced and readable by every agent and extension on the machine.', fix: 'Remove the token from the file, load it from the environment or a keychain, and rotate it.' },
|
|
185
|
+
'instructions-injection': { severity: 'high', title: 'Suspicious text in an agent instruction file', why: 'Instruction files are obeyed on every session. Hidden or "ignore the rules" text is how repos hijack agents and exfiltrate data.', fix: 'Read the file, remove the text, and check where it came from (cloned repo, PR, package).' },
|
|
186
|
+
'config-world-readable': { severity: 'medium', title: 'Agent config readable by other users', why: 'Other accounts and processes on this machine can read the config and any keys or tokens in it.', fix: 'chmod 600 the file (and 700 its folder).' },
|
|
187
|
+
};
|
|
188
|
+
export const RULES = R;
|
|
189
|
+
|
|
190
|
+
const HOME_LIKE = /^(?:\/|~\/?|\$HOME\/?|\$\{(?:env:)?(?:HOME|USERPROFILE|userHome)\}\/?|[A-Za-z]:\\?|\/(?:Users|home)\/[^/]+\/?|[A-Za-z]:\\Users\\[^\\]+\\?|\/root\/?)$/;
|
|
191
|
+
const SHELL_NAME = /(?:^|[-_@/ ])(?:shell|bash|zsh|powershell|terminal|exec|cmd|command|commands|run[-_]?command|desktop-commander|ssh|process)(?:$|[-_/ @.])/i;
|
|
192
|
+
|
|
193
|
+
function* servers(cfg) {
|
|
194
|
+
if (!cfg || typeof cfg !== 'object') return;
|
|
195
|
+
for (const key of ['mcpServers', 'servers', 'mcp_servers']) {
|
|
196
|
+
const s = cfg[key];
|
|
197
|
+
if (s && typeof s === 'object' && !Array.isArray(s)) for (const [name, def] of Object.entries(s)) if (def && typeof def === 'object') yield [name, def];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function pkgOf(command, args) {
|
|
202
|
+
const cmd = basename(String(command || '')).replace(/\.(cmd|exe)$/i, '').toLowerCase();
|
|
203
|
+
if (!['npx', 'uvx', 'bunx', 'pnpx'].includes(cmd) && !(cmd === 'pnpm' && args[0] === 'dlx') && !(cmd === 'uv' && args[0] === 'tool')) return null;
|
|
204
|
+
const rest = args.filter((a) => typeof a === 'string');
|
|
205
|
+
for (let i = 0; i < rest.length; i++) {
|
|
206
|
+
const a = rest[i];
|
|
207
|
+
if (['dlx', 'tool', 'run'].includes(a) && i < 2) continue;
|
|
208
|
+
if (a === '--from' || a === '-p' || a === '--package') return { cmd, pkg: rest[i + 1] || '' };
|
|
209
|
+
if (a.startsWith('-')) continue;
|
|
210
|
+
return { cmd, pkg: a };
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
export function isPinned(cmd, pkg) {
|
|
215
|
+
if (!pkg) return true;
|
|
216
|
+
if (/^(?:\.|\/|[A-Za-z]:\\|git\+|https?:|file:)/.test(pkg)) return true;
|
|
217
|
+
if (cmd === 'uvx' || cmd === 'uv') return /==\d|@\d/.test(pkg);
|
|
218
|
+
const at = pkg.startsWith('@') ? pkg.indexOf('@', 1) : pkg.indexOf('@');
|
|
219
|
+
if (at < 0) return false;
|
|
220
|
+
const ver = pkg.slice(at + 1);
|
|
221
|
+
return /^\d/.test(ver) && !/[\^~*x]|latest|next/.test(ver);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function checkServer(add, file, text, name, def) {
|
|
225
|
+
const args = Array.isArray(def.args) ? def.args.map(String) : [];
|
|
226
|
+
const url = def.url || def.serverUrl || def.httpUrl;
|
|
227
|
+
const at = (...n) => lineOf(text, `"${name}"`, ...n) || lineOf(text, name);
|
|
228
|
+
const where = (...n) => (file.endsWith('.toml') ? lineOf(text, `mcp_servers.${name}`, ...n) : at(...n));
|
|
229
|
+
// remote, no auth
|
|
230
|
+
if (url && /^https?:/i.test(url) && !/^https?:\/\/(?:localhost|127\.|\[::1\])/i.test(url)) {
|
|
231
|
+
const headers = def.headers || def.http_headers || def.env_http_headers || {};
|
|
232
|
+
const hasAuth = Object.keys(headers).some((h) => /auth|token|key/i.test(h)) || def.bearer_token_env_var || def.bearer_token || def.oauth || def.auth || /[?&](?:key|token|api_key|apikey)=/i.test(url);
|
|
233
|
+
if (!hasAuth) add('mcp-remote-no-auth', file, where('url'), `server "${name}" → ${url.replace(/[?#].*$/, '')}`);
|
|
234
|
+
}
|
|
235
|
+
// unpinned
|
|
236
|
+
const p = pkgOf(def.command, args);
|
|
237
|
+
if (p && !isPinned(p.cmd, p.pkg)) add('mcp-unpinned', file, where(p.pkg), `server "${name}": ${p.cmd} ${p.pkg}`);
|
|
238
|
+
// filesystem broad
|
|
239
|
+
const ident = `${name} ${def.command || ''} ${args.join(' ')}`;
|
|
240
|
+
if (/filesystem|file-system|\bfs\b|fs-mcp|mcp-fs/i.test(ident)) {
|
|
241
|
+
const broad = args.find((a) => HOME_LIKE.test(a.trim()));
|
|
242
|
+
if (broad) add('mcp-fs-broad', file, where(broad), `server "${name}" can access "${broad}"`);
|
|
243
|
+
}
|
|
244
|
+
// shell / exec
|
|
245
|
+
if (SHELL_NAME.test(name) || (p && SHELL_NAME.test(p.pkg)) || /mcp-server-commands|desktop-commander|mcp-shell|shell-mcp|terminal-mcp|mcp-server-shell/i.test(args.join(' ')))
|
|
246
|
+
add('mcp-shell', file, where(), `server "${name}"${p ? ` (${p.pkg})` : ''}`);
|
|
247
|
+
// plaintext secrets
|
|
248
|
+
const env = { ...(def.env || {}), ...(def.headers || {}), ...(def.http_headers || {}) };
|
|
249
|
+
for (const [k, v] of Object.entries(env)) {
|
|
250
|
+
if (typeof v !== 'string' || !v.trim() || isReference(v)) continue;
|
|
251
|
+
const tok = findToken(v);
|
|
252
|
+
const bearer = /^Bearer\s+(\S{8,})$/i.exec(v);
|
|
253
|
+
if (tok || bearer || (SECRET_NAME.test(k) && v.length >= 8 && !/\$\{/.test(v)))
|
|
254
|
+
add('mcp-plaintext-secret', file, where(k), `server "${name}", ${k} = ${mask(tok ? tok.value : bearer ? bearer[1] : v)}`, (tok || bearer || { value: v }).value);
|
|
255
|
+
}
|
|
256
|
+
const done = new Set();
|
|
257
|
+
args.forEach((a, i) => {
|
|
258
|
+
const tok = findToken(a);
|
|
259
|
+
const kv = /^--?(?:api[-_]?key|token|secret|password|auth)(?:=(.+))?$/i.exec(a);
|
|
260
|
+
const val = tok ? tok.value : kv ? kv[1] || args[i + 1] : null;
|
|
261
|
+
if (val && !done.has(val) && !isReference(val) && val.length >= 8 && done.add(val)) add('mcp-plaintext-secret', file, where(val), `server "${name}", args[${i}] = ${mask(val)}`, val);
|
|
262
|
+
});
|
|
263
|
+
if (url) { const t = findToken(url) || (/[?&](?:key|token|api_key|apikey)=([^&]{8,})/i.exec(url) || [])[1] && { value: /[?&](?:key|token|api_key|apikey)=([^&]+)/i.exec(url)[1] }; if (t) add('mcp-plaintext-secret', file, where('url'), `server "${name}", key in URL = ${mask(t.value)}`, t.value); }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const DANGER = [
|
|
267
|
+
[/\brm\b/, 'rm'], [/\b(?:curl|wget)\b.*\|\s*(?:ba|z)?sh\b|\|\s*(?:ba|z)?sh\b/, 'curl | sh'],
|
|
268
|
+
[/\bgit\s+push\b.*(?:--force|-f\b|\*)/, 'git push --force'], [/\bchmod\b/, 'chmod'], [/\bsudo\b/, 'sudo'],
|
|
269
|
+
[/\bRemove-Item\b/i, 'Remove-Item'],
|
|
270
|
+
];
|
|
271
|
+
function checkPermissions(add, file, text, allow, toolName = 'Bash') {
|
|
272
|
+
for (const rule of allow) {
|
|
273
|
+
if (typeof rule !== 'string') continue;
|
|
274
|
+
const r = rule.trim();
|
|
275
|
+
const m = /^(\w+)(?:\((.*)\))?$/s.exec(r);
|
|
276
|
+
const tool = m?.[1] || '';
|
|
277
|
+
const arg = (m?.[2] ?? '').trim();
|
|
278
|
+
if (r === '*' || ((/^(?:Bash|Shell|PowerShell)$/i.test(tool)) && (m[2] === undefined || /^[*:\s]*$/.test(arg) || arg === '**'))) {
|
|
279
|
+
add('perm-bypass', file, lineOf(text, JSON.stringify(rule)), `allow rule ${JSON.stringify(rule)}`); continue;
|
|
280
|
+
}
|
|
281
|
+
if (!/^(?:Bash|Shell|PowerShell)$/i.test(tool)) continue;
|
|
282
|
+
const hit = DANGER.find(([re]) => re.test(arg));
|
|
283
|
+
if (hit) add('perm-dangerous-allow', file, lineOf(text, JSON.stringify(rule)), `allow rule ${JSON.stringify(rule)} (${hit[1]})`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const INJECTION = [
|
|
288
|
+
[/\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system|safety|security|user'?s?)\b[^.\n]{0,20}\b(?:instructions?|rules?|prompts?|guidelines?|polic(?:y|ies))\b/i, 'tells the agent to ignore its rules'],
|
|
289
|
+
[/\b(?:do not|don'?t|never)\s+(?:tell|inform|mention|show|reveal|notify|ask)\b[^.\n]{0,30}\b(?:user|human|developer|anyone|owner)\b/i, 'tells the agent to hide actions from the user'],
|
|
290
|
+
[/\b(?:silently|secretly|covertly|without (?:telling|asking|notifying|informing))\b/i, 'asks for silent or covert actions'],
|
|
291
|
+
[/\b(?:curl|wget|fetch|invoke-webrequest|iwr|nc)\b[^\n]{0,120}(?:\$\{?[A-Z_]*(?:KEY|TOKEN|SECRET|PASS)|\.env\b|\.ssh|\.aws|id_rsa|credentials)/i, 'sends secrets or credentials to a URL'],
|
|
292
|
+
[/\b(?:send|post|upload|exfiltrate|forward|copy)\b[^.\n]{0,60}\b(?:env|secrets?|credentials?|tokens?|keys?|\.env|ssh)\b[^.\n]{0,60}https?:\/\//i, 'asks to send secrets to an external URL'],
|
|
293
|
+
[/https?:\/\/[^\s)"']*(?:webhook\.site|requestbin|pipedream\.net|ngrok(?:-free)?\.(?:io|app)|pastebin\.com|interact\.sh|oast\.|burpcollaborator|trycloudflare\.com)/i, 'points to a known data-capture endpoint'],
|
|
294
|
+
];
|
|
295
|
+
const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/u;
|
|
296
|
+
function checkInstructions(add, file, text) {
|
|
297
|
+
const lines = text.split('\n');
|
|
298
|
+
lines.forEach((l, i) => {
|
|
299
|
+
const inv = INVISIBLE.exec(l.replace(/^\uFEFF/, i === 0 ? '' : '\uFEFF'));
|
|
300
|
+
if (inv) add('instructions-injection', file, i + 1, `invisible Unicode U+${inv[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`);
|
|
301
|
+
for (const [re, label] of INJECTION) if (re.test(l)) { add('instructions-injection', file, i + 1, label); break; }
|
|
302
|
+
});
|
|
303
|
+
const hidden = /<!--[\s\S]*?-->/g; let m;
|
|
304
|
+
while ((m = hidden.exec(text))) if (INJECTION.some(([re]) => re.test(m[0])) || /\b(?:instruction|agent|assistant|AI)\b/i.test(m[0])) add('instructions-injection', file, text.slice(0, m.index).split('\n').length, 'instructions hidden in an HTML comment');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function listDir(dir, depth = 2) {
|
|
308
|
+
const out = [];
|
|
309
|
+
try {
|
|
310
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
311
|
+
const f = join(dir, e.name);
|
|
312
|
+
if (e.isDirectory() && depth > 0) out.push(...listDir(f, depth - 1));
|
|
313
|
+
else if (e.isFile() && /\.(md|mdc|txt)$/i.test(e.name)) out.push(f);
|
|
314
|
+
}
|
|
315
|
+
} catch { /* unreadable */ }
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------- main ----------
|
|
320
|
+
export function audit({ home = homedir(), cwd = process.cwd(), platform = process.platform, env = process.env } = {}) {
|
|
321
|
+
home = resolve(home); cwd = resolve(cwd);
|
|
322
|
+
const findings = []; const seen = new Set(); const files = []; const tools = new Set();
|
|
323
|
+
const add = (id, file, line, detail, secret) => {
|
|
324
|
+
const key = `${id}|${file}|${line}|${detail}`;
|
|
325
|
+
if (seen.has(key)) return;
|
|
326
|
+
seen.add(key);
|
|
327
|
+
if (secret && detail.includes(secret)) throw new Error('internal: unmasked secret');
|
|
328
|
+
findings.push({ id, severity: R[id].severity, title: R[id].title, file, line, detail, why: R[id].why, fix: R[id].fix });
|
|
329
|
+
};
|
|
330
|
+
let claudeSeen = false; const claudeDeny = []; const claudeFiles = [];
|
|
331
|
+
const visited = new Set();
|
|
332
|
+
for (const loc of locations({ home, cwd, platform, env })) {
|
|
333
|
+
const paths = loc.kind === 'instructions-dir' ? listDir(loc.path) : [loc.path];
|
|
334
|
+
for (const path of paths) {
|
|
335
|
+
if (visited.has(path)) continue; visited.add(path);
|
|
336
|
+
const r = readText(path);
|
|
337
|
+
if (!r) continue;
|
|
338
|
+
const { text, mode } = r;
|
|
339
|
+
files.push({ tool: loc.tool, scope: loc.scope, path });
|
|
340
|
+
tools.add(loc.tool);
|
|
341
|
+
const isInstr = loc.kind.startsWith('instructions');
|
|
342
|
+
if (isInstr) { checkInstructions(add, path, text); continue; }
|
|
343
|
+
let cfg;
|
|
344
|
+
if (loc.kind === 'codex') cfg = parseToml(text); else cfg = parseJsonLoose(text);
|
|
345
|
+
// tokens anywhere in the file (MCP-specific findings come first and win dedupe below)
|
|
346
|
+
const before = findings.length;
|
|
347
|
+
if (cfg) {
|
|
348
|
+
for (const [name, def] of servers(cfg)) checkServer(add, path, text, name, def);
|
|
349
|
+
if (loc.kind === 'claude-json') {
|
|
350
|
+
const proj = cfg.projects?.[cwd] || cfg.projects?.[cwd.split(sep).join('/')];
|
|
351
|
+
if (proj) for (const [name, def] of servers(proj)) checkServer(add, path, text, name, def);
|
|
352
|
+
}
|
|
353
|
+
if (loc.kind === 'claude-settings') {
|
|
354
|
+
claudeSeen = true; claudeFiles.push(path);
|
|
355
|
+
const perm = cfg.permissions || {};
|
|
356
|
+
const deny = Array.isArray(perm.deny) ? perm.deny : [];
|
|
357
|
+
claudeDeny.push(...deny);
|
|
358
|
+
if (perm.defaultMode === 'bypassPermissions' && (loc.scope === 'user' || loc.scope === 'managed')) add('perm-bypass', path, lineOf(text, 'defaultMode'), 'permissions.defaultMode = "bypassPermissions"');
|
|
359
|
+
if (cfg.skipDangerousModePermissionPrompt === true) add('perm-bypass', path, lineOf(text, 'skipDangerousModePermissionPrompt'), 'skipDangerousModePermissionPrompt = true');
|
|
360
|
+
checkPermissions(add, path, text, Array.isArray(perm.allow) ? perm.allow : []);
|
|
361
|
+
for (const [k, v] of Object.entries(cfg.env || {})) {
|
|
362
|
+
if (typeof v === 'string' && !isReference(v) && (findToken(v) || (SECRET_NAME.test(k) && v.length >= 12))) add('config-token', path, lineOf(text, `"${k}"`), `env.${k} = ${mask(findToken(v)?.value || v)}`, findToken(v)?.value || v);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (loc.kind === 'claude-json' && cfg.bypassPermissionsModeAccepted === true) { /* informational only */ }
|
|
366
|
+
if (loc.kind === 'cursor-cli') checkPermissions(add, path, text, Array.isArray(cfg.permissions?.allow) ? cfg.permissions.allow : [], 'Shell');
|
|
367
|
+
if (loc.kind === 'codex') {
|
|
368
|
+
const ap = cfg.approval_policy, sb = cfg.sandbox_mode;
|
|
369
|
+
if (ap === 'never' && sb === 'danger-full-access') add('perm-bypass', path, lineOf(text, 'sandbox_mode'), 'approval_policy = "never" with sandbox_mode = "danger-full-access"');
|
|
370
|
+
else if (sb === 'danger-full-access') add('perm-bypass', path, lineOf(text, 'sandbox_mode'), 'sandbox_mode = "danger-full-access" (no sandbox)');
|
|
371
|
+
for (const [pname, prof] of Object.entries(cfg.profiles || {})) if (prof?.sandbox_mode === 'danger-full-access') add('perm-bypass', path, lineOf(text, `profiles.${pname}`, 'sandbox_mode'), `profile "${pname}": sandbox_mode = "danger-full-access"`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// generic token scan for every config file, skipping lines already reported as a secret
|
|
375
|
+
const reported = new Set(findings.slice(before).filter((f) => f.id === 'mcp-plaintext-secret' || f.id === 'config-token').map((f) => f.line));
|
|
376
|
+
text.split('\n').forEach((l, i) => {
|
|
377
|
+
if (reported.has(i + 1)) return;
|
|
378
|
+
const t = findToken(l);
|
|
379
|
+
if (t) add('config-token', path, i + 1, `${t.name} = ${mask(t.value)}`, t.value);
|
|
380
|
+
});
|
|
381
|
+
// POSIX permissions: home-dir agent configs readable by group/others
|
|
382
|
+
if (platform !== 'win32' && (loc.scope === 'user') && (mode & 0o044)) {
|
|
383
|
+
const secretInside = findings.some((f) => f.file === path && (f.id === 'config-token' || f.id === 'mcp-plaintext-secret'));
|
|
384
|
+
if (secretInside || loc.kind === 'claude-json') add('config-world-readable', path, 1, `mode ${(mode & 0o777).toString(8)}${secretInside ? ', contains secrets' : ', holds your sign-in session'}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Claude Code deny rules for secret files (only if Claude Code settings exist)
|
|
389
|
+
if (claudeSeen) {
|
|
390
|
+
const d = claudeDeny.join(' ');
|
|
391
|
+
const missing = [['.env', /\.env/], ['~/.ssh', /\.ssh/], ['~/.aws', /\.aws/]].filter(([, re]) => !re.test(d)).map(([n]) => n);
|
|
392
|
+
if (missing.length) add('perm-no-deny-secrets', claudeFiles[0], 1, `no deny rule for ${missing.join(', ')}`);
|
|
393
|
+
}
|
|
394
|
+
// .env in the project (top level and one level down)
|
|
395
|
+
if (cwd !== home) {
|
|
396
|
+
const envs = [];
|
|
397
|
+
const scan = (dir, depth) => {
|
|
398
|
+
try {
|
|
399
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
400
|
+
if (e.isFile() && /^\.env(?:\.(?!example$|sample$|template$|dist$|defaults$)[\w.-]+)?$/.test(e.name)) envs.push(join(dir, e.name));
|
|
401
|
+
else if (e.isDirectory() && depth > 0 && !/^(?:node_modules|\.git|dist|build|\.venv|venv|vendor)$/.test(e.name) && !e.name.startsWith('.')) scan(join(dir, e.name), depth - 1);
|
|
402
|
+
}
|
|
403
|
+
} catch { /* unreadable */ }
|
|
404
|
+
};
|
|
405
|
+
scan(cwd, 1);
|
|
406
|
+
for (const f of envs) add('project-dotenv', f, 1, relative(cwd, f));
|
|
407
|
+
}
|
|
408
|
+
findings.sort((a, b) => SEVERITIES.indexOf(b.severity) - SEVERITIES.indexOf(a.severity) || a.file.localeCompare(b.file) || a.line - b.line);
|
|
409
|
+
const counts = Object.fromEntries(SEVERITIES.map((s) => [s, findings.filter((f) => f.severity === s).length]));
|
|
410
|
+
// Each rule costs its full weight once, then 2 points per extra occurrence.
|
|
411
|
+
const byRule = {};
|
|
412
|
+
for (const f of findings) byRule[f.id] = (byRule[f.id] || 0) + 1;
|
|
413
|
+
const score = Math.max(0, 100 - Object.entries(byRule).reduce((n, [id, k]) => n + WEIGHT[R[id].severity] + 2 * (k - 1), 0));
|
|
414
|
+
return { score, grade: score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F', counts, tools: [...tools], files, findings };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------- output ----------
|
|
418
|
+
export const CTA = 'Turn these into enforced policies: npx @vulnify/cli hook claude-code / vulnify.io';
|
|
419
|
+
export function format(result, { home = homedir(), cwd = process.cwd(), color = false } = {}) {
|
|
420
|
+
const c = (code, s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
421
|
+
const SEV = { critical: '1;31', high: '31', medium: '33', low: '36' };
|
|
422
|
+
const under = (p, base) => p.startsWith(base + sep) || p.startsWith(base + '/');
|
|
423
|
+
const short = (p) => (under(p, cwd) && cwd !== home ? '.' + p.slice(cwd.length) : under(p, home) ? '~' + p.slice(home.length) : p);
|
|
424
|
+
const out = [c('1', 'Vulnify audit') + c('2', ' read-only, nothing left this machine')];
|
|
425
|
+
out.push(c('2', `Checked ${result.files.length} config file${result.files.length === 1 ? '' : 's'}` + (result.tools.length ? ` (${result.tools.join(', ')})` : ' (no AI agent configs found)')));
|
|
426
|
+
out.push('');
|
|
427
|
+
if (!result.findings.length) out.push(c('32', 'No findings.'), '');
|
|
428
|
+
const groups = new Map();
|
|
429
|
+
for (const f of result.findings) groups.set(f.id, [...(groups.get(f.id) || []), f]);
|
|
430
|
+
for (const [, fs] of groups) {
|
|
431
|
+
const f = fs[0];
|
|
432
|
+
out.push(`${c(SEV[f.severity], f.severity.toUpperCase().padEnd(8))} ${c('1', f.title)}`);
|
|
433
|
+
for (const x of fs) out.push(` ${short(x.file)}:${x.line} ${c('2', x.detail)}`);
|
|
434
|
+
out.push(` Why: ${f.why}`, ` Fix: ${f.fix}`, '');
|
|
435
|
+
}
|
|
436
|
+
const parts = SEVERITIES.slice().reverse().filter((s) => result.counts[s]).map((s) => `${result.counts[s]} ${s}`);
|
|
437
|
+
out.push(`${c('1', `Score ${result.score}/100 (${result.grade})`)}${parts.length ? ' ' + parts.join(', ') : ''}`);
|
|
438
|
+
out.push(CTA);
|
|
439
|
+
return out.join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function shouldFail(result, failOn) {
|
|
443
|
+
if (!failOn) return false;
|
|
444
|
+
const min = SEVERITIES.indexOf(failOn);
|
|
445
|
+
return result.findings.some((f) => SEVERITIES.indexOf(f.severity) >= min);
|
|
446
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vulnify/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Vulnify CLI: install the fail-closed Vulnify
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Vulnify CLI: audit the AI agents on your machine (read-only, local) and install the fail-closed Vulnify hook into Claude Code.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": { "vulnify-cli": "bin/vulnify-cli.mjs" },
|
|
8
|
-
"files": ["bin", "hook", "README.md"],
|
|
8
|
+
"files": ["bin", "lib", "hook", "README.md"],
|
|
9
9
|
"engines": { "node": ">=18" },
|
|
10
10
|
"repository": { "type": "git", "url": "git+https://github.com/vulnify/vulnify-examples.git", "directory": "cli" },
|
|
11
11
|
"homepage": "https://docs.vulnify.io/mcp/claude-code-hook/",
|
|
12
|
-
"keywords": ["vulnify", "claude-code", "hooks", "pretooluse", "ai-agents", "guardrails", "security"],
|
|
12
|
+
"keywords": ["vulnify", "claude-code", "hooks", "pretooluse", "ai-agents", "guardrails", "security", "audit", "mcp", "cursor", "codex"],
|
|
13
13
|
"publishConfig": { "access": "public" },
|
|
14
14
|
"scripts": {
|
|
15
15
|
"sync-hook": "node -e \"require('fs').mkdirSync('hook',{recursive:true});require('fs').copyFileSync('../claude-code-hook/vulnify-hook.mjs','hook/vulnify-hook.mjs')\"",
|