@vulnify/cli 0.0.0-stage → 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 +45 -3
- package/bin/vulnify-cli.mjs +234 -0
- package/hook/vulnify-hook.mjs +327 -0
- package/lib/audit.mjs +446 -0
- package/package.json +19 -6
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @vulnify/cli
|
|
2
|
+
|
|
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
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx @vulnify/cli hook claude-code # this project (.claude/)
|
|
37
|
+
npx @vulnify/cli hook claude-code --user # every project (~/.claude/)
|
|
38
|
+
npx @vulnify/cli hook claude-code --uninstall [--user]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
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`.
|
|
42
|
+
|
|
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.
|
|
44
|
+
|
|
45
|
+
Docs: https://docs.vulnify.io/mcp/audit/ · https://docs.vulnify.io/mcp/claude-code-hook/
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* npx @vulnify/cli hook claude-code [--user] [--uninstall] [--yes]
|
|
4
|
+
* npx @vulnify/cli audit [--json] [--fail-on <severity>] [--dir <path>]
|
|
5
|
+
*
|
|
6
|
+
* Installs the fail-closed Vulnify PreToolUse hook into Claude Code settings.
|
|
7
|
+
* The API key comes from VULNIFY_API_KEY or a hidden prompt, and is never printed.
|
|
8
|
+
*/
|
|
9
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createInterface } from 'node:readline';
|
|
14
|
+
import { audit, format, shouldFail, SEVERITIES, CTA } from '../lib/audit.mjs';
|
|
15
|
+
|
|
16
|
+
const HOOK_FILE = 'vulnify-hook.mjs';
|
|
17
|
+
const MATCHER = 'Bash|PowerShell|Write|Edit|MultiEdit|NotebookEdit|WebFetch|mcp__.*';
|
|
18
|
+
const DOCS = 'https://docs.vulnify.io/mcp/claude-code-hook/';
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
export function paths({ user = false, cwd = process.cwd(), home = homedir() } = {}) {
|
|
22
|
+
const base = user ? join(home, '.claude') : join(resolve(cwd), '.claude');
|
|
23
|
+
return {
|
|
24
|
+
base,
|
|
25
|
+
hook: join(base, 'hooks', HOOK_FILE),
|
|
26
|
+
settings: join(base, 'settings.json'),
|
|
27
|
+
// Project scope keeps the key out of the committed settings.json.
|
|
28
|
+
keyFile: user ? join(base, 'settings.json') : join(base, 'settings.local.json'),
|
|
29
|
+
hookArg: user ? join(base, 'hooks', HOOK_FILE) : `\${CLAUDE_PROJECT_DIR}/.claude/hooks/${HOOK_FILE}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJson(file) {
|
|
34
|
+
if (!existsSync(file)) return {};
|
|
35
|
+
const raw = readFileSync(file, 'utf8').trim();
|
|
36
|
+
if (!raw) return {};
|
|
37
|
+
try {
|
|
38
|
+
const v = JSON.parse(raw);
|
|
39
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) return v;
|
|
40
|
+
} catch {
|
|
41
|
+
/* fall through */
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`${file} is not valid JSON. Fix it first; nothing was changed.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeJson(file, value, secret = false) {
|
|
47
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
48
|
+
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, secret ? { mode: 0o600 } : undefined);
|
|
49
|
+
if (secret) chmodSync(file, 0o600);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isVulnify = (h) => typeof h === 'object' && h && [h.command, ...(Array.isArray(h.args) ? h.args : [])].some((x) => typeof x === 'string' && x.includes(HOOK_FILE));
|
|
53
|
+
|
|
54
|
+
/** Remove every Vulnify hook entry from settings.hooks.PreToolUse. Returns true when something changed. */
|
|
55
|
+
export function removeHook(settings) {
|
|
56
|
+
const list = settings.hooks?.PreToolUse;
|
|
57
|
+
if (!Array.isArray(list)) return false;
|
|
58
|
+
let changed = false;
|
|
59
|
+
const next = [];
|
|
60
|
+
for (const group of list) {
|
|
61
|
+
const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
62
|
+
const kept = hooks.filter((h) => !isVulnify(h));
|
|
63
|
+
if (kept.length !== hooks.length) changed = true;
|
|
64
|
+
if (kept.length) next.push({ ...group, hooks: kept });
|
|
65
|
+
else if (!hooks.length) next.push(group);
|
|
66
|
+
}
|
|
67
|
+
if (!changed) return false;
|
|
68
|
+
if (next.length) settings.hooks.PreToolUse = next;
|
|
69
|
+
else delete settings.hooks.PreToolUse;
|
|
70
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function addHook(settings, hookArg) {
|
|
75
|
+
removeHook(settings);
|
|
76
|
+
settings.hooks ??= {};
|
|
77
|
+
settings.hooks.PreToolUse ??= [];
|
|
78
|
+
settings.hooks.PreToolUse.push({
|
|
79
|
+
matcher: MATCHER,
|
|
80
|
+
hooks: [{ type: 'command', command: 'node', args: [hookArg], timeout: 30, onFailure: 'block' }],
|
|
81
|
+
});
|
|
82
|
+
return settings;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ask(question, { hidden = false } = {}) {
|
|
86
|
+
return new Promise((done) => {
|
|
87
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
88
|
+
if (hidden) {
|
|
89
|
+
// Echo nothing while the key is typed.
|
|
90
|
+
rl._writeToOutput = (s) => {
|
|
91
|
+
if (s.startsWith(question)) process.stdout.write(question);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
rl.question(question, (answer) => {
|
|
95
|
+
rl.close();
|
|
96
|
+
if (hidden) process.stdout.write('\n');
|
|
97
|
+
done(answer.trim());
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensureGitignored(cwd, entry) {
|
|
103
|
+
if (!existsSync(join(cwd, '.git'))) return;
|
|
104
|
+
const file = join(cwd, '.gitignore');
|
|
105
|
+
const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
106
|
+
if (body.split(/\r?\n/).some((l) => l.trim() === entry)) return;
|
|
107
|
+
appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${entry}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function install({ user, yes, cwd = process.cwd(), home = homedir(), env = process.env, prompt = ask, log = console.log, interactive = !!process.stdin.isTTY }) {
|
|
111
|
+
const p = paths({ user, cwd, home });
|
|
112
|
+
const settings = readJson(p.settings);
|
|
113
|
+
const keySettings = p.keyFile === p.settings ? settings : readJson(p.keyFile);
|
|
114
|
+
|
|
115
|
+
let key = '';
|
|
116
|
+
let saveKey = false;
|
|
117
|
+
if (env.VULNIFY_API_KEY) {
|
|
118
|
+
log('Using VULNIFY_API_KEY from your environment (not saved; keep it exported where you start Claude Code).');
|
|
119
|
+
} else if (keySettings.env?.VULNIFY_API_KEY) {
|
|
120
|
+
log(`Keeping the API key already saved in ${p.keyFile}.`);
|
|
121
|
+
} else if (yes || !interactive) {
|
|
122
|
+
throw new Error('No API key. Export VULNIFY_API_KEY (a TEST key from app.vulnify.io/api-keys works) and run again.');
|
|
123
|
+
} else {
|
|
124
|
+
key = await prompt('Vulnify API key (input hidden, from app.vulnify.io/api-keys): ', { hidden: true });
|
|
125
|
+
if (!key) throw new Error('No API key entered; nothing was changed.');
|
|
126
|
+
if (!/^vln_/.test(key)) log('Note: Vulnify keys usually start with vln_. Saving it anyway.');
|
|
127
|
+
saveKey = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
mkdirSync(dirname(p.hook), { recursive: true });
|
|
131
|
+
copyFileSync(join(here, '..', 'hook', HOOK_FILE), p.hook);
|
|
132
|
+
addHook(settings, p.hookArg);
|
|
133
|
+
if (saveKey) {
|
|
134
|
+
keySettings.env = { ...(keySettings.env || {}), VULNIFY_API_KEY: key };
|
|
135
|
+
}
|
|
136
|
+
writeJson(p.settings, settings, p.keyFile === p.settings && saveKey);
|
|
137
|
+
if (saveKey && p.keyFile !== p.settings) {
|
|
138
|
+
writeJson(p.keyFile, keySettings, true);
|
|
139
|
+
ensureGitignored(cwd, '.claude/settings.local.json');
|
|
140
|
+
}
|
|
141
|
+
log(`Installed the Vulnify hook (${user ? 'user' : 'project'} scope):`);
|
|
142
|
+
log(` hook ${p.hook}`);
|
|
143
|
+
log(` settings ${p.settings}`);
|
|
144
|
+
if (saveKey) log(` API key saved in ${p.keyFile} (file mode 600${p.keyFile !== p.settings ? ', git-ignored' : ''})`);
|
|
145
|
+
log('Fail closed: if Vulnify cannot be reached, checked tool calls are held. Restart Claude Code, then run /hooks to confirm.');
|
|
146
|
+
log(`Docs: ${DOCS}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function uninstall({ user, cwd = process.cwd(), home = homedir(), log = console.log }) {
|
|
150
|
+
const p = paths({ user, cwd, home });
|
|
151
|
+
const settings = readJson(p.settings);
|
|
152
|
+
const removed = removeHook(settings);
|
|
153
|
+
let keyRemoved = false;
|
|
154
|
+
const keySettings = p.keyFile === p.settings ? settings : readJson(p.keyFile);
|
|
155
|
+
if (keySettings.env?.VULNIFY_API_KEY) {
|
|
156
|
+
delete keySettings.env.VULNIFY_API_KEY;
|
|
157
|
+
if (!Object.keys(keySettings.env).length) delete keySettings.env;
|
|
158
|
+
keyRemoved = true;
|
|
159
|
+
}
|
|
160
|
+
if (removed || (keyRemoved && p.keyFile === p.settings)) writeJson(p.settings, settings);
|
|
161
|
+
if (keyRemoved && p.keyFile !== p.settings) writeJson(p.keyFile, keySettings, true);
|
|
162
|
+
const hadFile = existsSync(p.hook);
|
|
163
|
+
if (hadFile) rmSync(p.hook);
|
|
164
|
+
log(removed || hadFile || keyRemoved ? `Removed the Vulnify hook (${user ? 'user' : 'project'} scope)${keyRemoved ? ' and the saved API key' : ''}.` : 'No Vulnify hook found here.');
|
|
165
|
+
}
|
|
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
|
+
|
|
197
|
+
const HELP = `Usage: npx @vulnify/cli hook claude-code [options]
|
|
198
|
+
npx @vulnify/cli audit [--json] [--fail-on <severity>]
|
|
199
|
+
|
|
200
|
+
Installs the fail-closed Vulnify PreToolUse hook into Claude Code.
|
|
201
|
+
|
|
202
|
+
Options:
|
|
203
|
+
--user install in ~/.claude (every project) instead of ./.claude
|
|
204
|
+
--uninstall remove the hook (and a key this tool saved)
|
|
205
|
+
--yes no prompts; needs VULNIFY_API_KEY in the environment
|
|
206
|
+
-h, --help show this help
|
|
207
|
+
|
|
208
|
+
Docs: ${DOCS}`;
|
|
209
|
+
|
|
210
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
211
|
+
if (argv[0] === 'audit') return runAudit(argv.slice(1));
|
|
212
|
+
const flags = new Set(argv.filter((a) => a.startsWith('-')));
|
|
213
|
+
const words = argv.filter((a) => !a.startsWith('-'));
|
|
214
|
+
if (flags.has('-h') || flags.has('--help') || words[0] !== 'hook' || words[1] !== 'claude-code') {
|
|
215
|
+
console.log(HELP);
|
|
216
|
+
return words.length && !(flags.has('-h') || flags.has('--help')) ? 1 : 0;
|
|
217
|
+
}
|
|
218
|
+
const unknown = [...flags].filter((f) => !['--user', '--uninstall', '--yes', '-y', '--project'].includes(f));
|
|
219
|
+
if (unknown.length) {
|
|
220
|
+
console.error(`Unknown option: ${unknown.join(' ')}\n\n${HELP}`);
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
const opts = { user: flags.has('--user'), yes: flags.has('--yes') || flags.has('-y') };
|
|
224
|
+
if (flags.has('--uninstall')) uninstall(opts);
|
|
225
|
+
else await install(opts);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (process.argv[1] && resolve(process.argv[1]).replace(/\\/g, '/').match(/vulnify-cli(\.mjs)?$/)) {
|
|
230
|
+
main().then((code) => process.exit(code), (e) => {
|
|
231
|
+
console.error(`vulnify-cli: ${String(e?.message || e)}`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
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 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
|
+
}
|
|
83
|
+
const num = (v) => {
|
|
84
|
+
const n = Number(String(v).replace(/[_,]/g, ''));
|
|
85
|
+
return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), MAX_RECORDS) : 0;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
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;
|
|
89
|
+
const EMAIL_ADDR = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
90
|
+
|
|
91
|
+
/** Rough number of records a shell command touches. 1 when nothing says otherwise. */
|
|
92
|
+
export function estimateBashRecords(c) {
|
|
93
|
+
const counts = [];
|
|
94
|
+
const sql = c.match(/\blimit\s+(\d+)/i);
|
|
95
|
+
if (sql) counts.push(num(sql[1]));
|
|
96
|
+
if (/\b(drop\s+(table|database|schema)|truncate(\s+table)?\s+\w)/i.test(c)) counts.push(BIG);
|
|
97
|
+
const del = c.match(/\b(delete\s+from|update)\s+[\w."]+([^;'"]*)/i);
|
|
98
|
+
if (del && !/\bwhere\b/i.test(del[2] || '') && !sql) counts.push(BIG);
|
|
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);
|
|
101
|
+
for (const m of c.matchAll(/\bseq\s+(?:\d+\s+)?(\d+)/g)) counts.push(num(m[1]));
|
|
102
|
+
for (const m of c.matchAll(/\{\d+\.\.(\d+)\}/g)) counts.push(num(m[1]));
|
|
103
|
+
for (const part of bashParts(c)) {
|
|
104
|
+
const words = part.split(/\s+/);
|
|
105
|
+
const cmd = words[0];
|
|
106
|
+
const args = words.slice(1).filter((w) => w && !w.startsWith('-'));
|
|
107
|
+
if (['rm', 'shred', 'unlink', 'rmdir'].includes(cmd)) {
|
|
108
|
+
const recursive = words.some((w) => /^-\w*[rR]/.test(w) || w === '--recursive');
|
|
109
|
+
const glob = args.some((a) => /[*?[]/.test(a));
|
|
110
|
+
counts.push(Math.max(args.length, recursive || glob ? FS_TREE : 0));
|
|
111
|
+
}
|
|
112
|
+
if (['scp', 'rsync', 'sftp'].includes(cmd) && args.length > 1) {
|
|
113
|
+
const recursive = words.some((w) => /^-\w*[ra]/.test(w));
|
|
114
|
+
counts.push(Math.max(args.length - 1, recursive ? 100 : 0));
|
|
115
|
+
}
|
|
116
|
+
if (['mail', 'mailx', 'mutt', 'sendmail', 'msmtp', 'ssmtp'].includes(cmd)) {
|
|
117
|
+
const rcpts = args.filter((a) => /@/.test(a)).length;
|
|
118
|
+
if (rcpts) counts.push(rcpts);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const rcpt = (c.match(/--mail-rcpt\b/g) || []).length;
|
|
122
|
+
if (rcpt) counts.push(rcpt);
|
|
123
|
+
return Math.max(1, ...counts.filter(Boolean));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isEmailCommand(c) {
|
|
127
|
+
return /(^|[\s;&|(])(sendmail|mailx?|mutt|swaks|msmtp|ssmtp|mpack|mail\.py)(\s|$)/.test(c)
|
|
128
|
+
|| /\bsmtps?:\/\//i.test(c) || /--mail-(rcpt|from)\b/.test(c)
|
|
129
|
+
|| /\baws\s+sesv?2?\s+send-(raw-)?email\b/.test(c) || /\bsmtplib\b|\bnodemailer\b|\bnet\/smtp\b|Send-MailMessage/i.test(c)
|
|
130
|
+
|| (/\b(curl|wget|http|httpie|xh)\b/.test(c) && EMAIL_API.test(c));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function classifyBash(command) {
|
|
134
|
+
const c = command;
|
|
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)
|
|
136
|
+
|| /git\s+(push\s+.*(--force|-f\b)|reset\s+--hard|clean\s+-\w*f|branch\s+-D)/.test(c)
|
|
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)) {
|
|
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' };
|
|
139
|
+
}
|
|
140
|
+
if (writesAgentConfig(c)) return { action: 'WRITE_DATA', destination: 'INTERNAL' };
|
|
141
|
+
if (isEmailCommand(c)) return { action: 'SEND_EMAIL', destination: 'EXTERNAL_EMAIL' };
|
|
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)
|
|
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)
|
|
144
|
+
|| /\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)
|
|
145
|
+
|| /\bgh\s+(release\s+upload|gist\s+create)\b/.test(c) || /hooks\.slack\.com|discord(app)?\.com\/api\/webhooks/.test(c)) {
|
|
146
|
+
return { action: 'EXPORT_DATA', destination: 'EXTERNAL_API' };
|
|
147
|
+
}
|
|
148
|
+
const parts = bashParts(c);
|
|
149
|
+
const readOnly = parts.length > 0 && parts.every((p) => {
|
|
150
|
+
if (/[^>]>[^&]|^>|>>/.test(p)) return false; // redirect writes a file
|
|
151
|
+
const [cmd, sub] = p.split(/\s+/);
|
|
152
|
+
if (cmd === 'git') return SAFE_GIT.has(sub);
|
|
153
|
+
if (cmd === 'npm' || cmd === 'pnpm' || cmd === 'yarn') return SAFE_NPM.has(sub);
|
|
154
|
+
return SAFE_BASH.has(cmd);
|
|
155
|
+
});
|
|
156
|
+
if (readOnly) return { action: 'READ_DATA', destination: 'INTERNAL' };
|
|
157
|
+
return { action: 'WRITE_DATA', destination: 'INTERNAL' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
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;
|
|
161
|
+
const RCPT_KEYS = /^(to|cc|bcc|recipients?|emails?|addresses|attendees|invitees|members|users|user_?ids|channels?)$/i;
|
|
162
|
+
const ID_LIST_KEYS = /(ids|items|records|rows|messages|files|documents|contacts|entries|keys|objects)$/i;
|
|
163
|
+
|
|
164
|
+
/** Recipients with an email address in an MCP tool input (to/cc/bcc/recipients...). */
|
|
165
|
+
function mcpRecipients(ti) {
|
|
166
|
+
let n = 0;
|
|
167
|
+
for (const [k, v] of Object.entries(ti || {})) {
|
|
168
|
+
if (!RCPT_KEYS.test(k)) continue;
|
|
169
|
+
const text = Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(',') : typeof v === 'string' ? v : '';
|
|
170
|
+
n += (text.match(EMAIL_ADDR) || []).length;
|
|
171
|
+
}
|
|
172
|
+
return n;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Rough number of records an MCP call touches: list lengths, limit/count fields, recipients. */
|
|
176
|
+
export function estimateMcpRecords(ti) {
|
|
177
|
+
const counts = [];
|
|
178
|
+
const walk = (v, key, depth) => {
|
|
179
|
+
if (depth > 3 || v == null) return;
|
|
180
|
+
if (Array.isArray(v)) {
|
|
181
|
+
if (ID_LIST_KEYS.test(key) || RCPT_KEYS.test(key) || v.length > 1) counts.push(Math.min(v.length, MAX_RECORDS));
|
|
182
|
+
for (const x of v.slice(0, 50)) walk(x, key, depth + 1);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (typeof v === 'object') {
|
|
186
|
+
for (const [k, x] of Object.entries(v)) walk(x, k, depth + 1);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (COUNT_KEYS.test(key) && (typeof v === 'number' || /^\d+$/.test(String(v)))) counts.push(num(v));
|
|
190
|
+
if (typeof v === 'string' && /^(query|sql|statement)$/i.test(key)) counts.push(estimateBashRecords(v));
|
|
191
|
+
};
|
|
192
|
+
walk(ti, '', 0);
|
|
193
|
+
counts.push(mcpRecipients(ti));
|
|
194
|
+
return Math.max(1, ...counts.filter(Boolean));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function classifyMcp(toolName, ti = {}) {
|
|
198
|
+
const [, server = 'mcp', ...rest] = toolName.split('__');
|
|
199
|
+
const rawTool = rest.join('__');
|
|
200
|
+
const tool = rawTool.toLowerCase();
|
|
201
|
+
const words = rawTool.split(/[^A-Za-z0-9]+|(?=[A-Z])/).map((w) => w.toLowerCase());
|
|
202
|
+
const has = (re) => words.some((w) => re.test(w));
|
|
203
|
+
const mailish = /mail|gmail|outlook|smtp|sendgrid|resend|mailgun|postmark|ses\b|brevo|mailchimp/i.test(server) || /mail/.test(tool) || mcpRecipients(ti) > 0;
|
|
204
|
+
const sqlish = typeof (ti.sql ?? ti.query) === 'string' ? String(ti.sql ?? ti.query) : '';
|
|
205
|
+
let out;
|
|
206
|
+
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' };
|
|
207
|
+
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' };
|
|
208
|
+
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' };
|
|
209
|
+
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' };
|
|
210
|
+
else out = { action: 'WRITE_DATA', destination: 'EXTERNAL_API' };
|
|
211
|
+
return { ...out, resource: clip(`mcp:${server}`, 100) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Map a PreToolUse input to a Vulnify event body, or null when the tool is skipped. */
|
|
215
|
+
export function describe(input, cfg = config()) {
|
|
216
|
+
const name = String(input.tool_name || '');
|
|
217
|
+
const ti = input.tool_input || {};
|
|
218
|
+
if (!name || cfg.skip.has(name)) return null;
|
|
219
|
+
if (/^mcp__vulnify__/.test(name)) return null; // Vulnify's own tools
|
|
220
|
+
let ev;
|
|
221
|
+
let content = '';
|
|
222
|
+
if (name === 'Bash' || name === 'PowerShell') {
|
|
223
|
+
const command = String(ti.command || '');
|
|
224
|
+
ev = { ...classifyBash(command), resource: 'shell', records: estimateBashRecords(command) };
|
|
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;
|
|
229
|
+
} else if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(name)) {
|
|
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;
|
|
235
|
+
} else if (name === 'WebFetch') {
|
|
236
|
+
ev = { action: 'READ_DATA', destination: 'EXTERNAL_API', resource: 'web' };
|
|
237
|
+
content = String(ti.url || '');
|
|
238
|
+
} else if (name.startsWith('mcp__')) {
|
|
239
|
+
ev = { ...classifyMcp(name, ti), records: estimateMcpRecords(ti) };
|
|
240
|
+
content = JSON.stringify(ti);
|
|
241
|
+
} else {
|
|
242
|
+
ev = { action: 'WRITE_DATA', destination: 'INTERNAL', resource: clip(`tool:${name}`, 100) };
|
|
243
|
+
content = JSON.stringify(ti);
|
|
244
|
+
}
|
|
245
|
+
if (ev.action === 'READ_DATA' && !cfg.checkReads && name !== 'WebFetch') return null;
|
|
246
|
+
const body = { agent: cfg.agent, action: ev.action, resource: ev.resource, destination: ev.destination, recordsAffected: ev.records || 1 };
|
|
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);
|
|
250
|
+
return body;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function out(decision, reason, extra = {}) {
|
|
254
|
+
const hookSpecificOutput = { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: reason };
|
|
255
|
+
return { hookSpecificOutput, ...extra };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function call(cfg, fetchImpl, method, path, body, idem) {
|
|
259
|
+
const ctrl = new AbortController();
|
|
260
|
+
const t = setTimeout(() => ctrl.abort(), cfg.timeoutMs);
|
|
261
|
+
try {
|
|
262
|
+
const headers = { Authorization: `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': 'vulnify-claude-code-hook/0.2' };
|
|
263
|
+
if (idem) headers['Idempotency-Key'] = idem;
|
|
264
|
+
const res = await fetchImpl(cfg.baseUrl + path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: ctrl.signal });
|
|
265
|
+
const json = await res.json().catch(() => ({}));
|
|
266
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}${json.message ? `: ${String(json.message).slice(0, 200)}` : ''}`);
|
|
267
|
+
return json;
|
|
268
|
+
} finally {
|
|
269
|
+
clearTimeout(t);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Decide one PreToolUse input. Returns the JSON to print, or null for "no decision". */
|
|
274
|
+
export async function decide(input, { cfg = config(), fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
275
|
+
const body = describe(input, cfg);
|
|
276
|
+
if (!body) return null;
|
|
277
|
+
const degraded = (why) => cfg.failOpen
|
|
278
|
+
? { systemMessage: `Vulnify degraded (${why}); ${input.tool_name} ran without a check because VULNIFY_HOOK_FAIL_OPEN=true.` }
|
|
279
|
+
: 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.`);
|
|
280
|
+
if (!cfg.apiKey) return degraded('VULNIFY_API_KEY is not set');
|
|
281
|
+
let d;
|
|
282
|
+
try {
|
|
283
|
+
d = await call(cfg, fetchImpl, 'POST', '/v1/events', body, input.tool_use_id ? `cc-${input.tool_use_id}` : undefined);
|
|
284
|
+
let final = d.finalDecision ?? d.decision;
|
|
285
|
+
const deadline = Date.now() + cfg.reviewWaitMs;
|
|
286
|
+
while (final === 'REVIEW' && d.id && Date.now() < deadline) {
|
|
287
|
+
await sleep(Math.min(3000, Math.max(0, deadline - Date.now())));
|
|
288
|
+
d = await call(cfg, fetchImpl, 'GET', `/v1/events/${encodeURIComponent(d.id)}`);
|
|
289
|
+
final = d.finalDecision ?? d.decision;
|
|
290
|
+
}
|
|
291
|
+
} catch (e) {
|
|
292
|
+
return degraded(e?.name === 'AbortError' ? 'timeout' : String(e?.message || e).slice(0, 200));
|
|
293
|
+
}
|
|
294
|
+
const final = d.finalDecision ?? d.decision;
|
|
295
|
+
const reasons = Array.isArray(d.reasons) && d.reasons.length ? d.reasons.join('; ') : 'no reasons given';
|
|
296
|
+
const tag = `${body.action}${body.destination ? ` -> ${body.destination}` : ''}`;
|
|
297
|
+
if (final === 'ALLOW') return cfg.allowMode === 'allow' ? out('allow', `Vulnify ALLOW (${tag}, event ${d.id})`) : null;
|
|
298
|
+
if (final === 'REVIEW') {
|
|
299
|
+
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.`);
|
|
300
|
+
}
|
|
301
|
+
if (final === 'BLOCK') return out('deny', `Vulnify BLOCK (${tag}, event ${d.id}): ${reasons}. Do not retry or work around this; tell the user.`);
|
|
302
|
+
return degraded(`unexpected decision ${String(final).slice(0, 40)}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function main() {
|
|
306
|
+
let raw = '';
|
|
307
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
308
|
+
let input;
|
|
309
|
+
try {
|
|
310
|
+
input = JSON.parse(raw || '{}');
|
|
311
|
+
} catch {
|
|
312
|
+
const cfg = config();
|
|
313
|
+
if (cfg.failOpen) return;
|
|
314
|
+
process.stderr.write('Vulnify hook: could not parse PreToolUse input; holding the action (fail closed).\n');
|
|
315
|
+
process.exit(2);
|
|
316
|
+
}
|
|
317
|
+
const result = await decide(input);
|
|
318
|
+
if (result) process.stdout.write(JSON.stringify(result));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('vulnify-hook.mjs')) {
|
|
322
|
+
main().catch((e) => {
|
|
323
|
+
if (process.env.VULNIFY_HOOK_FAIL_OPEN === 'true') return;
|
|
324
|
+
process.stderr.write(`Vulnify hook failed: ${String(e?.message || e).slice(0, 200)}. Holding the action (fail closed).\n`);
|
|
325
|
+
process.exit(2);
|
|
326
|
+
});
|
|
327
|
+
}
|
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,6 +1,19 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@vulnify/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@vulnify/cli",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "vulnify-cli": "bin/vulnify-cli.mjs" },
|
|
8
|
+
"files": ["bin", "lib", "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", "audit", "mcp", "cursor", "codex"],
|
|
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
|
+
}
|