@vulnify/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/bin/vulnify-cli.mjs +200 -0
- package/hook/vulnify-hook.mjs +291 -0
- package/package.json +19 -6
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @vulnify/cli
|
|
2
|
+
|
|
3
|
+
Install the fail-closed [Vulnify](https://www.vulnify.io) PreToolUse hook into Claude Code:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @vulnify/cli hook claude-code # this project (.claude/)
|
|
7
|
+
npx @vulnify/cli hook claude-code --user # every project (~/.claude/)
|
|
8
|
+
npx @vulnify/cli hook claude-code --uninstall [--user]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The API key comes from `VULNIFY_API_KEY`, or a hidden prompt. It is never printed. A prompted key is saved in `.claude/settings.local.json` (git-ignored, mode 600) for a project, or in `~/.claude/settings.json` with `--user`.
|
|
12
|
+
|
|
13
|
+
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
|
+
|
|
15
|
+
Docs: https://docs.vulnify.io/mcp/claude-code-hook/
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* npx @vulnify/cli hook claude-code [--user] [--uninstall] [--yes]
|
|
4
|
+
*
|
|
5
|
+
* Installs the fail-closed Vulnify PreToolUse hook into Claude Code settings.
|
|
6
|
+
* The API key comes from VULNIFY_API_KEY or a hidden prompt, and is never printed.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { createInterface } from 'node:readline';
|
|
13
|
+
|
|
14
|
+
const HOOK_FILE = 'vulnify-hook.mjs';
|
|
15
|
+
const MATCHER = 'Bash|PowerShell|Write|Edit|MultiEdit|NotebookEdit|WebFetch|mcp__.*';
|
|
16
|
+
const DOCS = 'https://docs.vulnify.io/mcp/claude-code-hook/';
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
export function paths({ user = false, cwd = process.cwd(), home = homedir() } = {}) {
|
|
20
|
+
const base = user ? join(home, '.claude') : join(resolve(cwd), '.claude');
|
|
21
|
+
return {
|
|
22
|
+
base,
|
|
23
|
+
hook: join(base, 'hooks', HOOK_FILE),
|
|
24
|
+
settings: join(base, 'settings.json'),
|
|
25
|
+
// Project scope keeps the key out of the committed settings.json.
|
|
26
|
+
keyFile: user ? join(base, 'settings.json') : join(base, 'settings.local.json'),
|
|
27
|
+
hookArg: user ? join(base, 'hooks', HOOK_FILE) : `\${CLAUDE_PROJECT_DIR}/.claude/hooks/${HOOK_FILE}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readJson(file) {
|
|
32
|
+
if (!existsSync(file)) return {};
|
|
33
|
+
const raw = readFileSync(file, 'utf8').trim();
|
|
34
|
+
if (!raw) return {};
|
|
35
|
+
try {
|
|
36
|
+
const v = JSON.parse(raw);
|
|
37
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) return v;
|
|
38
|
+
} catch {
|
|
39
|
+
/* fall through */
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`${file} is not valid JSON. Fix it first; nothing was changed.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeJson(file, value, secret = false) {
|
|
45
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
46
|
+
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, secret ? { mode: 0o600 } : undefined);
|
|
47
|
+
if (secret) chmodSync(file, 0o600);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isVulnify = (h) => typeof h === 'object' && h && [h.command, ...(Array.isArray(h.args) ? h.args : [])].some((x) => typeof x === 'string' && x.includes(HOOK_FILE));
|
|
51
|
+
|
|
52
|
+
/** Remove every Vulnify hook entry from settings.hooks.PreToolUse. Returns true when something changed. */
|
|
53
|
+
export function removeHook(settings) {
|
|
54
|
+
const list = settings.hooks?.PreToolUse;
|
|
55
|
+
if (!Array.isArray(list)) return false;
|
|
56
|
+
let changed = false;
|
|
57
|
+
const next = [];
|
|
58
|
+
for (const group of list) {
|
|
59
|
+
const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
60
|
+
const kept = hooks.filter((h) => !isVulnify(h));
|
|
61
|
+
if (kept.length !== hooks.length) changed = true;
|
|
62
|
+
if (kept.length) next.push({ ...group, hooks: kept });
|
|
63
|
+
else if (!hooks.length) next.push(group);
|
|
64
|
+
}
|
|
65
|
+
if (!changed) return false;
|
|
66
|
+
if (next.length) settings.hooks.PreToolUse = next;
|
|
67
|
+
else delete settings.hooks.PreToolUse;
|
|
68
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function addHook(settings, hookArg) {
|
|
73
|
+
removeHook(settings);
|
|
74
|
+
settings.hooks ??= {};
|
|
75
|
+
settings.hooks.PreToolUse ??= [];
|
|
76
|
+
settings.hooks.PreToolUse.push({
|
|
77
|
+
matcher: MATCHER,
|
|
78
|
+
hooks: [{ type: 'command', command: 'node', args: [hookArg], timeout: 30, onFailure: 'block' }],
|
|
79
|
+
});
|
|
80
|
+
return settings;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ask(question, { hidden = false } = {}) {
|
|
84
|
+
return new Promise((done) => {
|
|
85
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
86
|
+
if (hidden) {
|
|
87
|
+
// Echo nothing while the key is typed.
|
|
88
|
+
rl._writeToOutput = (s) => {
|
|
89
|
+
if (s.startsWith(question)) process.stdout.write(question);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
rl.question(question, (answer) => {
|
|
93
|
+
rl.close();
|
|
94
|
+
if (hidden) process.stdout.write('\n');
|
|
95
|
+
done(answer.trim());
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ensureGitignored(cwd, entry) {
|
|
101
|
+
if (!existsSync(join(cwd, '.git'))) return;
|
|
102
|
+
const file = join(cwd, '.gitignore');
|
|
103
|
+
const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
104
|
+
if (body.split(/\r?\n/).some((l) => l.trim() === entry)) return;
|
|
105
|
+
appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${entry}\n`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function install({ user, yes, cwd = process.cwd(), home = homedir(), env = process.env, prompt = ask, log = console.log, interactive = !!process.stdin.isTTY }) {
|
|
109
|
+
const p = paths({ user, cwd, home });
|
|
110
|
+
const settings = readJson(p.settings);
|
|
111
|
+
const keySettings = p.keyFile === p.settings ? settings : readJson(p.keyFile);
|
|
112
|
+
|
|
113
|
+
let key = '';
|
|
114
|
+
let saveKey = false;
|
|
115
|
+
if (env.VULNIFY_API_KEY) {
|
|
116
|
+
log('Using VULNIFY_API_KEY from your environment (not saved; keep it exported where you start Claude Code).');
|
|
117
|
+
} else if (keySettings.env?.VULNIFY_API_KEY) {
|
|
118
|
+
log(`Keeping the API key already saved in ${p.keyFile}.`);
|
|
119
|
+
} else if (yes || !interactive) {
|
|
120
|
+
throw new Error('No API key. Export VULNIFY_API_KEY (a TEST key from app.vulnify.io/api-keys works) and run again.');
|
|
121
|
+
} else {
|
|
122
|
+
key = await prompt('Vulnify API key (input hidden, from app.vulnify.io/api-keys): ', { hidden: true });
|
|
123
|
+
if (!key) throw new Error('No API key entered; nothing was changed.');
|
|
124
|
+
if (!/^vln_/.test(key)) log('Note: Vulnify keys usually start with vln_. Saving it anyway.');
|
|
125
|
+
saveKey = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
mkdirSync(dirname(p.hook), { recursive: true });
|
|
129
|
+
copyFileSync(join(here, '..', 'hook', HOOK_FILE), p.hook);
|
|
130
|
+
addHook(settings, p.hookArg);
|
|
131
|
+
if (saveKey) {
|
|
132
|
+
keySettings.env = { ...(keySettings.env || {}), VULNIFY_API_KEY: key };
|
|
133
|
+
}
|
|
134
|
+
writeJson(p.settings, settings, p.keyFile === p.settings && saveKey);
|
|
135
|
+
if (saveKey && p.keyFile !== p.settings) {
|
|
136
|
+
writeJson(p.keyFile, keySettings, true);
|
|
137
|
+
ensureGitignored(cwd, '.claude/settings.local.json');
|
|
138
|
+
}
|
|
139
|
+
log(`Installed the Vulnify hook (${user ? 'user' : 'project'} scope):`);
|
|
140
|
+
log(` hook ${p.hook}`);
|
|
141
|
+
log(` settings ${p.settings}`);
|
|
142
|
+
if (saveKey) log(` API key saved in ${p.keyFile} (file mode 600${p.keyFile !== p.settings ? ', git-ignored' : ''})`);
|
|
143
|
+
log('Fail closed: if Vulnify cannot be reached, checked tool calls are held. Restart Claude Code, then run /hooks to confirm.');
|
|
144
|
+
log(`Docs: ${DOCS}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function uninstall({ user, cwd = process.cwd(), home = homedir(), log = console.log }) {
|
|
148
|
+
const p = paths({ user, cwd, home });
|
|
149
|
+
const settings = readJson(p.settings);
|
|
150
|
+
const removed = removeHook(settings);
|
|
151
|
+
let keyRemoved = false;
|
|
152
|
+
const keySettings = p.keyFile === p.settings ? settings : readJson(p.keyFile);
|
|
153
|
+
if (keySettings.env?.VULNIFY_API_KEY) {
|
|
154
|
+
delete keySettings.env.VULNIFY_API_KEY;
|
|
155
|
+
if (!Object.keys(keySettings.env).length) delete keySettings.env;
|
|
156
|
+
keyRemoved = true;
|
|
157
|
+
}
|
|
158
|
+
if (removed || (keyRemoved && p.keyFile === p.settings)) writeJson(p.settings, settings);
|
|
159
|
+
if (keyRemoved && p.keyFile !== p.settings) writeJson(p.keyFile, keySettings, true);
|
|
160
|
+
const hadFile = existsSync(p.hook);
|
|
161
|
+
if (hadFile) rmSync(p.hook);
|
|
162
|
+
log(removed || hadFile || keyRemoved ? `Removed the Vulnify hook (${user ? 'user' : 'project'} scope)${keyRemoved ? ' and the saved API key' : ''}.` : 'No Vulnify hook found here.');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const HELP = `Usage: npx @vulnify/cli hook claude-code [options]
|
|
166
|
+
|
|
167
|
+
Installs the fail-closed Vulnify PreToolUse hook into Claude Code.
|
|
168
|
+
|
|
169
|
+
Options:
|
|
170
|
+
--user install in ~/.claude (every project) instead of ./.claude
|
|
171
|
+
--uninstall remove the hook (and a key this tool saved)
|
|
172
|
+
--yes no prompts; needs VULNIFY_API_KEY in the environment
|
|
173
|
+
-h, --help show this help
|
|
174
|
+
|
|
175
|
+
Docs: ${DOCS}`;
|
|
176
|
+
|
|
177
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
178
|
+
const flags = new Set(argv.filter((a) => a.startsWith('-')));
|
|
179
|
+
const words = argv.filter((a) => !a.startsWith('-'));
|
|
180
|
+
if (flags.has('-h') || flags.has('--help') || words[0] !== 'hook' || words[1] !== 'claude-code') {
|
|
181
|
+
console.log(HELP);
|
|
182
|
+
return words.length && !(flags.has('-h') || flags.has('--help')) ? 1 : 0;
|
|
183
|
+
}
|
|
184
|
+
const unknown = [...flags].filter((f) => !['--user', '--uninstall', '--yes', '-y', '--project'].includes(f));
|
|
185
|
+
if (unknown.length) {
|
|
186
|
+
console.error(`Unknown option: ${unknown.join(' ')}\n\n${HELP}`);
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
const opts = { user: flags.has('--user'), yes: flags.has('--yes') || flags.has('-y') };
|
|
190
|
+
if (flags.has('--uninstall')) uninstall(opts);
|
|
191
|
+
else await install(opts);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (process.argv[1] && resolve(process.argv[1]).replace(/\\/g, '/').match(/vulnify-cli(\.mjs)?$/)) {
|
|
196
|
+
main().then((code) => process.exit(code), (e) => {
|
|
197
|
+
console.error(`vulnify-cli: ${String(e?.message || e)}`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Vulnify PreToolUse hook for Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* Reads the PreToolUse JSON from stdin, maps the tool call to a Vulnify event,
|
|
6
|
+
* calls POST /v1/events, and prints a Claude Code hook decision:
|
|
7
|
+
* ALLOW -> no decision (Claude Code's normal permission flow), or "allow" with VULNIFY_HOOK_ALLOW_MODE=allow
|
|
8
|
+
* REVIEW -> deny: waiting for approval in Vulnify (optionally waits VULNIFY_HOOK_REVIEW_WAIT_MS)
|
|
9
|
+
* BLOCK -> deny with the reasons
|
|
10
|
+
* Errors fail closed (deny) unless VULNIFY_HOOK_FAIL_OPEN=true.
|
|
11
|
+
* No dependencies. Node 18+.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEFAULT_SKIP = [
|
|
15
|
+
'Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'TodoWrite', 'Task', 'Agent', 'WebSearch',
|
|
16
|
+
'BashOutput', 'KillShell', 'KillBash', 'ExitPlanMode', 'AskUserQuestion', 'ListMcpResourcesTool', 'ReadMcpResourceTool',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Bash commands that only read. A command is skipped only when every part of it is in this list.
|
|
20
|
+
const SAFE_BASH = new Set([
|
|
21
|
+
'ls', 'cat', 'head', 'tail', 'less', 'more', 'grep', 'rg', 'egrep', 'fgrep', 'find', 'fd', 'pwd', 'echo', 'printf',
|
|
22
|
+
'wc', 'sort', 'uniq', 'cut', 'tr', 'diff', 'stat', 'file', 'which', 'whoami', 'date', 'env', 'printenv', 'tree',
|
|
23
|
+
'du', 'df', 'true', 'false', 'test', 'cd', 'jq', 'basename', 'dirname', 'realpath', 'type', 'uname', 'sleep',
|
|
24
|
+
]);
|
|
25
|
+
const SAFE_GIT = new Set(['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'ls-files', 'blame', 'remote', 'fetch', 'grep', 'describe', 'tag']);
|
|
26
|
+
const SAFE_NPM = new Set(['test', 'run', 'ls', 'view', 'outdated', 'audit']);
|
|
27
|
+
|
|
28
|
+
function envList(name, env = process.env) {
|
|
29
|
+
return (env[name] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function config(env = process.env) {
|
|
33
|
+
const skip = new Set([...(env.VULNIFY_HOOK_SKIP_DEFAULTS === 'false' ? [] : DEFAULT_SKIP), ...envList('VULNIFY_HOOK_SKIP', env)]);
|
|
34
|
+
return {
|
|
35
|
+
apiKey: env.VULNIFY_API_KEY || '',
|
|
36
|
+
baseUrl: (env.VULNIFY_BASE_URL || 'https://api.vulnify.io').replace(/\/+$/, ''),
|
|
37
|
+
agent: env.VULNIFY_AGENT || 'claude-code',
|
|
38
|
+
skip,
|
|
39
|
+
checkReads: env.VULNIFY_HOOK_CHECK_READS === 'true',
|
|
40
|
+
failOpen: env.VULNIFY_HOOK_FAIL_OPEN === 'true',
|
|
41
|
+
allowMode: env.VULNIFY_HOOK_ALLOW_MODE === 'allow' ? 'allow' : 'defer',
|
|
42
|
+
timeoutMs: Number(env.VULNIFY_HOOK_TIMEOUT_MS) || 8000,
|
|
43
|
+
reviewWaitMs: Math.min(Number(env.VULNIFY_HOOK_REVIEW_WAIT_MS) || 0, 120000),
|
|
44
|
+
sendContent: ['never', 'risky', 'always'].includes(env.VULNIFY_HOOK_SEND_CONTENT) ? env.VULNIFY_HOOK_SEND_CONTENT : 'risky',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n) : s);
|
|
49
|
+
|
|
50
|
+
function bashParts(command) {
|
|
51
|
+
return command.split(/&&|\|\||[;|\n]/).map((p) => p.trim().replace(/^(\w+=\S*\s+)+/, '')).filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MAX_RECORDS = 1_000_000;
|
|
55
|
+
const BIG = 10_000; // "the whole table": DELETE without WHERE, DROP, TRUNCATE
|
|
56
|
+
const num = (v) => {
|
|
57
|
+
const n = Number(String(v).replace(/[_,]/g, ''));
|
|
58
|
+
return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), MAX_RECORDS) : 0;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const EMAIL_API = /\b(api\.sendgrid\.com|api\.mailgun\.net|api\.resend\.com|api\.postmarkapp\.com|api\.mailjet\.com|api\.brevo\.com|api\.sendinblue\.com|email(-smtp)?\.[a-z0-9-]+\.amazonaws\.com|mandrillapp\.com|api\.sparkpost\.com|smtp\.)/i;
|
|
62
|
+
const EMAIL_ADDR = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
63
|
+
|
|
64
|
+
/** Rough number of records a shell command touches. 1 when nothing says otherwise. */
|
|
65
|
+
export function estimateBashRecords(c) {
|
|
66
|
+
const counts = [];
|
|
67
|
+
const sql = c.match(/\blimit\s+(\d+)/i);
|
|
68
|
+
if (sql) counts.push(num(sql[1]));
|
|
69
|
+
if (/\b(drop\s+(table|database|schema)|truncate(\s+table)?\s+\w)/i.test(c)) counts.push(BIG);
|
|
70
|
+
const del = c.match(/\b(delete\s+from|update)\s+[\w."]+([^;'"]*)/i);
|
|
71
|
+
if (del && !/\bwhere\b/i.test(del[2] || '') && !sql) counts.push(BIG);
|
|
72
|
+
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]));
|
|
73
|
+
for (const m of c.matchAll(/\bseq\s+(?:\d+\s+)?(\d+)/g)) counts.push(num(m[1]));
|
|
74
|
+
for (const m of c.matchAll(/\{\d+\.\.(\d+)\}/g)) counts.push(num(m[1]));
|
|
75
|
+
for (const part of bashParts(c)) {
|
|
76
|
+
const words = part.split(/\s+/);
|
|
77
|
+
const cmd = words[0];
|
|
78
|
+
const args = words.slice(1).filter((w) => w && !w.startsWith('-'));
|
|
79
|
+
if (['rm', 'shred', 'unlink', 'rmdir'].includes(cmd)) {
|
|
80
|
+
const recursive = words.some((w) => /^-\w*[rR]/.test(w) || w === '--recursive');
|
|
81
|
+
const glob = args.some((a) => /[*?[]/.test(a));
|
|
82
|
+
counts.push(Math.max(args.length, recursive || glob ? 100 : 0));
|
|
83
|
+
}
|
|
84
|
+
if (['scp', 'rsync', 'sftp'].includes(cmd) && args.length > 1) {
|
|
85
|
+
const recursive = words.some((w) => /^-\w*[ra]/.test(w));
|
|
86
|
+
counts.push(Math.max(args.length - 1, recursive ? 100 : 0));
|
|
87
|
+
}
|
|
88
|
+
if (['mail', 'mailx', 'mutt', 'sendmail', 'msmtp', 'ssmtp'].includes(cmd)) {
|
|
89
|
+
const rcpts = args.filter((a) => /@/.test(a)).length;
|
|
90
|
+
if (rcpts) counts.push(rcpts);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const rcpt = (c.match(/--mail-rcpt\b/g) || []).length;
|
|
94
|
+
if (rcpt) counts.push(rcpt);
|
|
95
|
+
return Math.max(1, ...counts.filter(Boolean));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isEmailCommand(c) {
|
|
99
|
+
return /(^|[\s;&|(])(sendmail|mailx?|mutt|swaks|msmtp|ssmtp|mpack|mail\.py)(\s|$)/.test(c)
|
|
100
|
+
|| /\bsmtps?:\/\//i.test(c) || /--mail-(rcpt|from)\b/.test(c)
|
|
101
|
+
|| /\baws\s+sesv?2?\s+send-(raw-)?email\b/.test(c) || /\bsmtplib\b|\bnodemailer\b|\bnet\/smtp\b|Send-MailMessage/i.test(c)
|
|
102
|
+
|| (/\b(curl|wget|http|httpie|xh)\b/.test(c) && EMAIL_API.test(c));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function classifyBash(command) {
|
|
106
|
+
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)
|
|
108
|
+
|| /git\s+(push\s+.*(--force|-f\b)|reset\s+--hard|clean\s+-\w*f|branch\s+-D)/.test(c)
|
|
109
|
+
|| /\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
|
+
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
|
+
}
|
|
112
|
+
if (isEmailCommand(c)) return { action: 'SEND_EMAIL', destination: 'EXTERNAL_EMAIL' };
|
|
113
|
+
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
|
+
|| /\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)
|
|
115
|
+
|| /\b(aws\s+s3\s+(cp|sync|mv)|gsutil\s+(cp|rsync|mv)|rclone|azcopy|az\s+storage\s+blob\s+upload)\b/.test(c)
|
|
116
|
+
|| /\bgh\s+(release\s+upload|gist\s+create)\b/.test(c) || /hooks\.slack\.com|discord(app)?\.com\/api\/webhooks/.test(c)) {
|
|
117
|
+
return { action: 'EXPORT_DATA', destination: 'EXTERNAL_API' };
|
|
118
|
+
}
|
|
119
|
+
const parts = bashParts(c);
|
|
120
|
+
const readOnly = parts.length > 0 && parts.every((p) => {
|
|
121
|
+
if (/[^>]>[^&]|^>|>>/.test(p)) return false; // redirect writes a file
|
|
122
|
+
const [cmd, sub] = p.split(/\s+/);
|
|
123
|
+
if (cmd === 'git') return SAFE_GIT.has(sub);
|
|
124
|
+
if (cmd === 'npm' || cmd === 'pnpm' || cmd === 'yarn') return SAFE_NPM.has(sub);
|
|
125
|
+
return SAFE_BASH.has(cmd);
|
|
126
|
+
});
|
|
127
|
+
if (readOnly) return { action: 'READ_DATA', destination: 'INTERNAL' };
|
|
128
|
+
return { action: 'WRITE_DATA', destination: 'INTERNAL' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const COUNT_KEYS = /^(limit|count|max_?results|max_?count|page_?size|per_?page|batch_?size|num_?records|records|rows|quantity|top|first|last|n)$/i;
|
|
132
|
+
const RCPT_KEYS = /^(to|cc|bcc|recipients?|emails?|addresses|attendees|invitees|members|users|user_?ids|channels?)$/i;
|
|
133
|
+
const ID_LIST_KEYS = /(ids|items|records|rows|messages|files|documents|contacts|entries|keys|objects)$/i;
|
|
134
|
+
|
|
135
|
+
/** Recipients with an email address in an MCP tool input (to/cc/bcc/recipients...). */
|
|
136
|
+
function mcpRecipients(ti) {
|
|
137
|
+
let n = 0;
|
|
138
|
+
for (const [k, v] of Object.entries(ti || {})) {
|
|
139
|
+
if (!RCPT_KEYS.test(k)) continue;
|
|
140
|
+
const text = Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(',') : typeof v === 'string' ? v : '';
|
|
141
|
+
n += (text.match(EMAIL_ADDR) || []).length;
|
|
142
|
+
}
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Rough number of records an MCP call touches: list lengths, limit/count fields, recipients. */
|
|
147
|
+
export function estimateMcpRecords(ti) {
|
|
148
|
+
const counts = [];
|
|
149
|
+
const walk = (v, key, depth) => {
|
|
150
|
+
if (depth > 3 || v == null) return;
|
|
151
|
+
if (Array.isArray(v)) {
|
|
152
|
+
if (ID_LIST_KEYS.test(key) || RCPT_KEYS.test(key) || v.length > 1) counts.push(Math.min(v.length, MAX_RECORDS));
|
|
153
|
+
for (const x of v.slice(0, 50)) walk(x, key, depth + 1);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (typeof v === 'object') {
|
|
157
|
+
for (const [k, x] of Object.entries(v)) walk(x, k, depth + 1);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (COUNT_KEYS.test(key) && (typeof v === 'number' || /^\d+$/.test(String(v)))) counts.push(num(v));
|
|
161
|
+
if (typeof v === 'string' && /^(query|sql|statement)$/i.test(key)) counts.push(estimateBashRecords(v));
|
|
162
|
+
};
|
|
163
|
+
walk(ti, '', 0);
|
|
164
|
+
counts.push(mcpRecipients(ti));
|
|
165
|
+
return Math.max(1, ...counts.filter(Boolean));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function classifyMcp(toolName, ti = {}) {
|
|
169
|
+
const [, server = 'mcp', ...rest] = toolName.split('__');
|
|
170
|
+
const rawTool = rest.join('__');
|
|
171
|
+
const tool = rawTool.toLowerCase();
|
|
172
|
+
const words = rawTool.split(/[^A-Za-z0-9]+|(?=[A-Z])/).map((w) => w.toLowerCase());
|
|
173
|
+
const has = (re) => words.some((w) => re.test(w));
|
|
174
|
+
const mailish = /mail|gmail|outlook|smtp|sendgrid|resend|mailgun|postmark|ses\b|brevo|mailchimp/i.test(server) || /mail/.test(tool) || mcpRecipients(ti) > 0;
|
|
175
|
+
const sqlish = typeof (ti.sql ?? ti.query) === 'string' ? String(ti.sql ?? ti.query) : '';
|
|
176
|
+
let out;
|
|
177
|
+
if (has(/^(delete|remove|drop|purge|destroy|trash|archive|revoke|cancel|truncate)/) || /\b(delete\s+from|drop\s+table|truncate)\b/i.test(sqlish)) out = { action: 'DELETE_DATA', destination: 'EXTERNAL_API' };
|
|
178
|
+
else if (has(/^(send|reply|forward|post|message|notify|email|dm|chat|draft)/)) out = mailish ? { action: 'SEND_EMAIL', destination: 'EXTERNAL_EMAIL' } : { action: 'EXPORT_DATA', destination: 'EXTERNAL_API' };
|
|
179
|
+
else if (has(/^(export|share|upload|publish|invite|transfer|pay|charge|refund|payout|deploy|sync|backup|dump)/)) out = mailish && has(/^(share|invite)/) ? { action: 'EXPORT_DATA', destination: 'EXTERNAL_EMAIL' } : { action: 'EXPORT_DATA', destination: 'EXTERNAL_API' };
|
|
180
|
+
else if (has(/^(get|list|search|read|fetch|query|find|describe|lookup|view|show|check|count|retrieve|download)$/) && !/\b(insert|update|delete|drop|alter|create)\b/i.test(sqlish)) out = { action: 'READ_DATA', destination: 'EXTERNAL_API' };
|
|
181
|
+
else out = { action: 'WRITE_DATA', destination: 'EXTERNAL_API' };
|
|
182
|
+
return { ...out, resource: clip(`mcp:${server}`, 100) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Map a PreToolUse input to a Vulnify event body, or null when the tool is skipped. */
|
|
186
|
+
export function describe(input, cfg = config()) {
|
|
187
|
+
const name = String(input.tool_name || '');
|
|
188
|
+
const ti = input.tool_input || {};
|
|
189
|
+
if (!name || cfg.skip.has(name)) return null;
|
|
190
|
+
if (/^mcp__vulnify__/.test(name)) return null; // Vulnify's own tools
|
|
191
|
+
let ev;
|
|
192
|
+
let content = '';
|
|
193
|
+
if (name === 'Bash' || name === 'PowerShell') {
|
|
194
|
+
const command = String(ti.command || '');
|
|
195
|
+
ev = { ...classifyBash(command), resource: 'shell', records: estimateBashRecords(command) };
|
|
196
|
+
content = command;
|
|
197
|
+
} else if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(name)) {
|
|
198
|
+
ev = { action: 'WRITE_DATA', destination: 'INTERNAL', resource: 'local files' };
|
|
199
|
+
content = String(ti.content ?? ti.new_string ?? ti.new_source ?? '');
|
|
200
|
+
} else if (name === 'WebFetch') {
|
|
201
|
+
ev = { action: 'READ_DATA', destination: 'EXTERNAL_API', resource: 'web' };
|
|
202
|
+
content = String(ti.url || '');
|
|
203
|
+
} else if (name.startsWith('mcp__')) {
|
|
204
|
+
ev = { ...classifyMcp(name, ti), records: estimateMcpRecords(ti) };
|
|
205
|
+
content = JSON.stringify(ti);
|
|
206
|
+
} else {
|
|
207
|
+
ev = { action: 'WRITE_DATA', destination: 'INTERNAL', resource: clip(`tool:${name}`, 100) };
|
|
208
|
+
content = JSON.stringify(ti);
|
|
209
|
+
}
|
|
210
|
+
if (ev.action === 'READ_DATA' && !cfg.checkReads && name !== 'WebFetch') return null;
|
|
211
|
+
const body = { agent: cfg.agent, action: ev.action, resource: ev.resource, destination: ev.destination, recordsAffected: ev.records || 1 };
|
|
212
|
+
const risky = ['SEND_EMAIL', 'EXPORT_DATA', 'DELETE_DATA'].includes(ev.action) || name === 'WebFetch';
|
|
213
|
+
if (content && (cfg.sendContent === 'always' || (cfg.sendContent === 'risky' && risky))) body.content = clip(content, 20000);
|
|
214
|
+
return body;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function out(decision, reason, extra = {}) {
|
|
218
|
+
const hookSpecificOutput = { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: reason };
|
|
219
|
+
return { hookSpecificOutput, ...extra };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function call(cfg, fetchImpl, method, path, body, idem) {
|
|
223
|
+
const ctrl = new AbortController();
|
|
224
|
+
const t = setTimeout(() => ctrl.abort(), cfg.timeoutMs);
|
|
225
|
+
try {
|
|
226
|
+
const headers = { Authorization: `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': 'vulnify-claude-code-hook/0.2' };
|
|
227
|
+
if (idem) headers['Idempotency-Key'] = idem;
|
|
228
|
+
const res = await fetchImpl(cfg.baseUrl + path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: ctrl.signal });
|
|
229
|
+
const json = await res.json().catch(() => ({}));
|
|
230
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}${json.message ? `: ${String(json.message).slice(0, 200)}` : ''}`);
|
|
231
|
+
return json;
|
|
232
|
+
} finally {
|
|
233
|
+
clearTimeout(t);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Decide one PreToolUse input. Returns the JSON to print, or null for "no decision". */
|
|
238
|
+
export async function decide(input, { cfg = config(), fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
239
|
+
const body = describe(input, cfg);
|
|
240
|
+
if (!body) return null;
|
|
241
|
+
const degraded = (why) => cfg.failOpen
|
|
242
|
+
? { systemMessage: `Vulnify degraded (${why}); ${input.tool_name} ran without a check because VULNIFY_HOOK_FAIL_OPEN=true.` }
|
|
243
|
+
: out('deny', `Vulnify could not check this action (${why}), so it was held (fail closed). Tell the user; do not try to work around it.`);
|
|
244
|
+
if (!cfg.apiKey) return degraded('VULNIFY_API_KEY is not set');
|
|
245
|
+
let d;
|
|
246
|
+
try {
|
|
247
|
+
d = await call(cfg, fetchImpl, 'POST', '/v1/events', body, input.tool_use_id ? `cc-${input.tool_use_id}` : undefined);
|
|
248
|
+
let final = d.finalDecision ?? d.decision;
|
|
249
|
+
const deadline = Date.now() + cfg.reviewWaitMs;
|
|
250
|
+
while (final === 'REVIEW' && d.id && Date.now() < deadline) {
|
|
251
|
+
await sleep(Math.min(3000, Math.max(0, deadline - Date.now())));
|
|
252
|
+
d = await call(cfg, fetchImpl, 'GET', `/v1/events/${encodeURIComponent(d.id)}`);
|
|
253
|
+
final = d.finalDecision ?? d.decision;
|
|
254
|
+
}
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return degraded(e?.name === 'AbortError' ? 'timeout' : String(e?.message || e).slice(0, 200));
|
|
257
|
+
}
|
|
258
|
+
const final = d.finalDecision ?? d.decision;
|
|
259
|
+
const reasons = Array.isArray(d.reasons) && d.reasons.length ? d.reasons.join('; ') : 'no reasons given';
|
|
260
|
+
const tag = `${body.action}${body.destination ? ` -> ${body.destination}` : ''}`;
|
|
261
|
+
if (final === 'ALLOW') return cfg.allowMode === 'allow' ? out('allow', `Vulnify ALLOW (${tag}, event ${d.id})`) : null;
|
|
262
|
+
if (final === 'REVIEW') {
|
|
263
|
+
return out('deny', `Vulnify REVIEW (${tag}, event ${d.id}): waiting for approval in Vulnify. Reasons: ${reasons}. Tell the user a person must approve it at app.vulnify.io/approvals, then retry after approval.`);
|
|
264
|
+
}
|
|
265
|
+
if (final === 'BLOCK') return out('deny', `Vulnify BLOCK (${tag}, event ${d.id}): ${reasons}. Do not retry or work around this; tell the user.`);
|
|
266
|
+
return degraded(`unexpected decision ${String(final).slice(0, 40)}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function main() {
|
|
270
|
+
let raw = '';
|
|
271
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
272
|
+
let input;
|
|
273
|
+
try {
|
|
274
|
+
input = JSON.parse(raw || '{}');
|
|
275
|
+
} catch {
|
|
276
|
+
const cfg = config();
|
|
277
|
+
if (cfg.failOpen) return;
|
|
278
|
+
process.stderr.write('Vulnify hook: could not parse PreToolUse input; holding the action (fail closed).\n');
|
|
279
|
+
process.exit(2);
|
|
280
|
+
}
|
|
281
|
+
const result = await decide(input);
|
|
282
|
+
if (result) process.stdout.write(JSON.stringify(result));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('vulnify-hook.mjs')) {
|
|
286
|
+
main().catch((e) => {
|
|
287
|
+
if (process.env.VULNIFY_HOOK_FAIL_OPEN === 'true') return;
|
|
288
|
+
process.stderr.write(`Vulnify hook failed: ${String(e?.message || e).slice(0, 200)}. Holding the action (fail closed).\n`);
|
|
289
|
+
process.exit(2);
|
|
290
|
+
});
|
|
291
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@vulnify/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@vulnify/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vulnify CLI: install the fail-closed Vulnify PreToolUse hook into Claude Code with one command.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "vulnify-cli": "bin/vulnify-cli.mjs" },
|
|
8
|
+
"files": ["bin", "hook", "README.md"],
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/vulnify/vulnify-examples.git", "directory": "cli" },
|
|
11
|
+
"homepage": "https://docs.vulnify.io/mcp/claude-code-hook/",
|
|
12
|
+
"keywords": ["vulnify", "claude-code", "hooks", "pretooluse", "ai-agents", "guardrails", "security"],
|
|
13
|
+
"publishConfig": { "access": "public" },
|
|
14
|
+
"scripts": {
|
|
15
|
+
"sync-hook": "node -e \"require('fs').mkdirSync('hook',{recursive:true});require('fs').copyFileSync('../claude-code-hook/vulnify-hook.mjs','hook/vulnify-hook.mjs')\"",
|
|
16
|
+
"prepack": "npm run sync-hook",
|
|
17
|
+
"test": "npm run sync-hook && node --test"
|
|
18
|
+
}
|
|
19
|
+
}
|