@safetnsr/vet 1.22.2 → 1.25.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/dist/checks/sandbox.d.ts +3 -0
- package/dist/checks/sandbox.js +280 -0
- package/dist/checks/source-security.d.ts +2 -0
- package/dist/checks/source-security.js +153 -0
- package/dist/cli.js +77 -5
- package/dist/utils-bad.d.ts +6 -0
- package/dist/utils-bad.js +39 -0
- package/package.json +1 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { statSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { readFile, c } from '../util.js';
|
|
5
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
6
|
+
const SENSITIVE_DIRS = [
|
|
7
|
+
'~/.ssh',
|
|
8
|
+
'~/.aws',
|
|
9
|
+
'~/.gnupg',
|
|
10
|
+
'~/.config/gcloud',
|
|
11
|
+
'~/.kube',
|
|
12
|
+
'~/.docker',
|
|
13
|
+
'~/.npmrc',
|
|
14
|
+
'~/.pypirc',
|
|
15
|
+
'~/.netrc',
|
|
16
|
+
];
|
|
17
|
+
const ENV_PATTERNS = [
|
|
18
|
+
/KEY/i,
|
|
19
|
+
/SECRET/i,
|
|
20
|
+
/TOKEN/i,
|
|
21
|
+
/PASSWORD/i,
|
|
22
|
+
/CREDENTIAL/i,
|
|
23
|
+
/AUTH/i,
|
|
24
|
+
];
|
|
25
|
+
const NETWORK_RESTRICTION_PATTERNS = [
|
|
26
|
+
/allowedUrls/i,
|
|
27
|
+
/blockedUrls/i,
|
|
28
|
+
/networkRestrict/i,
|
|
29
|
+
/network.*allow/i,
|
|
30
|
+
/network.*block/i,
|
|
31
|
+
/allowlist/i,
|
|
32
|
+
/denylist/i,
|
|
33
|
+
/block.*network/i,
|
|
34
|
+
];
|
|
35
|
+
// ── Probe 1: Sensitive dirs ───────────────────────────────────────────────────
|
|
36
|
+
function probeSensitiveDirs() {
|
|
37
|
+
const issues = [];
|
|
38
|
+
let deduction = 0;
|
|
39
|
+
const home = homedir();
|
|
40
|
+
for (const dir of SENSITIVE_DIRS) {
|
|
41
|
+
const resolved = dir.replace('~', home);
|
|
42
|
+
try {
|
|
43
|
+
statSync(resolved);
|
|
44
|
+
// accessible
|
|
45
|
+
deduction += 1;
|
|
46
|
+
issues.push({
|
|
47
|
+
severity: 'error',
|
|
48
|
+
message: `Sensitive directory accessible: ${dir}`,
|
|
49
|
+
fixable: false,
|
|
50
|
+
fixHint: 'Run agent in a sandboxed environment (Docker, VM, chroot) to restrict fs access',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// not accessible — good
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { deduction, issues };
|
|
58
|
+
}
|
|
59
|
+
// ── Probe 2: Env var leaks ────────────────────────────────────────────────────
|
|
60
|
+
function probeEnvVars() {
|
|
61
|
+
const issues = [];
|
|
62
|
+
let rawDeduction = 0;
|
|
63
|
+
for (const key of Object.keys(process.env)) {
|
|
64
|
+
const matches = ENV_PATTERNS.some(re => re.test(key));
|
|
65
|
+
if (matches) {
|
|
66
|
+
rawDeduction += 0.5;
|
|
67
|
+
issues.push({
|
|
68
|
+
severity: 'warning',
|
|
69
|
+
message: `Sensitive env var exposed: ${key}`,
|
|
70
|
+
fixable: false,
|
|
71
|
+
fixHint: 'Use a secrets manager or strip sensitive vars before running agent',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const deduction = Math.min(rawDeduction, 3);
|
|
76
|
+
return { deduction, issues };
|
|
77
|
+
}
|
|
78
|
+
// ── Probe 3: Network rules ────────────────────────────────────────────────────
|
|
79
|
+
function probeNetworkRules(cwd) {
|
|
80
|
+
const issues = [];
|
|
81
|
+
let deduction = 0;
|
|
82
|
+
const filesToCheck = ['CLAUDE.md', 'AGENTS.md'];
|
|
83
|
+
let found = false;
|
|
84
|
+
for (const filename of filesToCheck) {
|
|
85
|
+
const content = readFile(join(cwd, filename));
|
|
86
|
+
if (!content)
|
|
87
|
+
continue;
|
|
88
|
+
const hasRestriction = NETWORK_RESTRICTION_PATTERNS.some(re => re.test(content));
|
|
89
|
+
if (hasRestriction) {
|
|
90
|
+
found = true;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!found) {
|
|
95
|
+
deduction = 1;
|
|
96
|
+
issues.push({
|
|
97
|
+
severity: 'warning',
|
|
98
|
+
message: 'No network restriction rules found in CLAUDE.md or AGENTS.md',
|
|
99
|
+
fixable: false,
|
|
100
|
+
fixHint: 'Add allowedUrls or blockedUrls rules to CLAUDE.md to limit agent network access',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { deduction, issues };
|
|
104
|
+
}
|
|
105
|
+
// ── Probe 4: MCP permissions ──────────────────────────────────────────────────
|
|
106
|
+
function probeMcpPermissions(cwd) {
|
|
107
|
+
const issues = [];
|
|
108
|
+
let rawDeduction = 0;
|
|
109
|
+
const settingsPath = join(cwd, '.claude', 'settings.json');
|
|
110
|
+
if (!existsSync(settingsPath)) {
|
|
111
|
+
issues.push({
|
|
112
|
+
severity: 'info',
|
|
113
|
+
message: 'No .claude/settings.json found — cannot audit MCP permissions',
|
|
114
|
+
fixable: false,
|
|
115
|
+
fixHint: 'Create .claude/settings.json with explicit MCP permission scopes',
|
|
116
|
+
});
|
|
117
|
+
return { deduction: 0, issues };
|
|
118
|
+
}
|
|
119
|
+
let settings;
|
|
120
|
+
try {
|
|
121
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
issues.push({
|
|
125
|
+
severity: 'warning',
|
|
126
|
+
message: 'Failed to parse .claude/settings.json',
|
|
127
|
+
file: '.claude/settings.json',
|
|
128
|
+
fixable: false,
|
|
129
|
+
});
|
|
130
|
+
return { deduction: 0, issues };
|
|
131
|
+
}
|
|
132
|
+
// Check mcpServers for tools with filesystem:write or no path restrictions
|
|
133
|
+
const mcpServers = settings.mcpServers || {};
|
|
134
|
+
for (const [serverName, server] of Object.entries(mcpServers)) {
|
|
135
|
+
const srv = server;
|
|
136
|
+
const tools = srv.tools || [];
|
|
137
|
+
for (const tool of tools) {
|
|
138
|
+
const permissions = tool.permissions || [];
|
|
139
|
+
const hasWriteAccess = permissions.includes('filesystem:write');
|
|
140
|
+
const hasNoPathRestriction = !permissions.some(p => p.startsWith('path:'));
|
|
141
|
+
if (hasWriteAccess && hasNoPathRestriction) {
|
|
142
|
+
rawDeduction += 1;
|
|
143
|
+
issues.push({
|
|
144
|
+
severity: 'error',
|
|
145
|
+
message: `MCP tool with unrestricted filesystem:write: ${serverName}/${tool.name || 'unknown'}`,
|
|
146
|
+
file: '.claude/settings.json',
|
|
147
|
+
fixable: false,
|
|
148
|
+
fixHint: 'Add path: restrictions to limit filesystem write access',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Also check top-level permissions on the server
|
|
153
|
+
const serverPermissions = srv.permissions || [];
|
|
154
|
+
const hasWriteAccess = serverPermissions.includes('filesystem:write');
|
|
155
|
+
const hasNoPathRestriction = !serverPermissions.some((p) => p.startsWith('path:'));
|
|
156
|
+
if (hasWriteAccess && hasNoPathRestriction) {
|
|
157
|
+
rawDeduction += 1;
|
|
158
|
+
issues.push({
|
|
159
|
+
severity: 'error',
|
|
160
|
+
message: `MCP server with unrestricted filesystem:write: ${serverName}`,
|
|
161
|
+
file: '.claude/settings.json',
|
|
162
|
+
fixable: false,
|
|
163
|
+
fixHint: 'Add path: restrictions to limit filesystem write access',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const deduction = Math.min(rawDeduction, 2);
|
|
168
|
+
return { deduction, issues };
|
|
169
|
+
}
|
|
170
|
+
// ── Blast radius score ────────────────────────────────────────────────────────
|
|
171
|
+
function blastRadiusLabel(score) {
|
|
172
|
+
if (score >= 9)
|
|
173
|
+
return 'minimal — agent is tightly sandboxed';
|
|
174
|
+
if (score >= 7)
|
|
175
|
+
return 'low — some exposure, mostly contained';
|
|
176
|
+
if (score >= 5)
|
|
177
|
+
return 'moderate — agent can access sensitive resources';
|
|
178
|
+
if (score >= 3)
|
|
179
|
+
return 'high — agent has broad filesystem and secret access';
|
|
180
|
+
return 'critical — agent is running in a fully open environment';
|
|
181
|
+
}
|
|
182
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
183
|
+
export async function checkSandbox(cwd) {
|
|
184
|
+
const allIssues = [];
|
|
185
|
+
const sensitiveDirs = probeSensitiveDirs();
|
|
186
|
+
const envVars = probeEnvVars();
|
|
187
|
+
const networkRules = probeNetworkRules(cwd);
|
|
188
|
+
const mcpPerms = probeMcpPermissions(cwd);
|
|
189
|
+
allIssues.push(...sensitiveDirs.issues);
|
|
190
|
+
allIssues.push(...envVars.issues);
|
|
191
|
+
allIssues.push(...networkRules.issues);
|
|
192
|
+
allIssues.push(...mcpPerms.issues);
|
|
193
|
+
const totalDeduction = sensitiveDirs.deduction + envVars.deduction + networkRules.deduction + mcpPerms.deduction;
|
|
194
|
+
const sandboxScore = Math.max(0, Math.min(10, 10 - totalDeduction));
|
|
195
|
+
const score = Math.round(sandboxScore * 10);
|
|
196
|
+
const label = blastRadiusLabel(sandboxScore);
|
|
197
|
+
const summary = `blast radius score ${sandboxScore.toFixed(1)}/10 — ${label}`;
|
|
198
|
+
return {
|
|
199
|
+
name: 'sandbox',
|
|
200
|
+
score,
|
|
201
|
+
maxScore: 100,
|
|
202
|
+
issues: allIssues,
|
|
203
|
+
summary,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
// ── Subcommand output ────────────────────────────────────────────────────────
|
|
207
|
+
export async function runSandboxCommand(cwd, flags) {
|
|
208
|
+
const result = await checkSandbox(cwd);
|
|
209
|
+
const sandboxScore = result.score / 10;
|
|
210
|
+
if (flags.has('--json')) {
|
|
211
|
+
console.log(JSON.stringify({
|
|
212
|
+
score: sandboxScore,
|
|
213
|
+
scoreOutOf100: result.score,
|
|
214
|
+
maxScore: result.maxScore,
|
|
215
|
+
blastRadius: blastRadiusLabel(sandboxScore),
|
|
216
|
+
issues: result.issues,
|
|
217
|
+
summary: result.summary,
|
|
218
|
+
}, null, 2));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
console.log(`\n ${c.bold}vet sandbox${c.reset} — agent runtime blast radius\n`);
|
|
222
|
+
// Table header
|
|
223
|
+
const labelW = 30;
|
|
224
|
+
const statusW = 12;
|
|
225
|
+
console.log(` ${c.dim}${'─'.repeat(labelW + statusW + 6)}${c.reset}`);
|
|
226
|
+
console.log(` ${pad('Probe', labelW)} ${pad('Status', statusW)}`);
|
|
227
|
+
console.log(` ${c.dim}${'─'.repeat(labelW + statusW + 6)}${c.reset}`);
|
|
228
|
+
// Probe 1: Sensitive dirs
|
|
229
|
+
const sensitiveDirIssues = result.issues.filter(i => i.message.startsWith('Sensitive directory'));
|
|
230
|
+
const sensitiveDirStatus = sensitiveDirIssues.length === 0
|
|
231
|
+
? `${c.green}PASS${c.reset}`
|
|
232
|
+
: `${c.red}FAIL (${sensitiveDirIssues.length})${c.reset}`;
|
|
233
|
+
console.log(` ${pad('Sensitive dirs', labelW)} ${sensitiveDirStatus}`);
|
|
234
|
+
for (const issue of sensitiveDirIssues) {
|
|
235
|
+
console.log(` ${c.red}✗${c.reset} ${issue.message}`);
|
|
236
|
+
}
|
|
237
|
+
// Probe 2: Env var leaks
|
|
238
|
+
const envIssues = result.issues.filter(i => i.message.startsWith('Sensitive env var'));
|
|
239
|
+
const envStatus = envIssues.length === 0
|
|
240
|
+
? `${c.green}PASS${c.reset}`
|
|
241
|
+
: `${c.yellow}WARN (${envIssues.length})${c.reset}`;
|
|
242
|
+
console.log(` ${pad('Env var exposure', labelW)} ${envStatus}`);
|
|
243
|
+
for (const issue of envIssues.slice(0, 5)) {
|
|
244
|
+
console.log(` ${c.yellow}⚠${c.reset} ${issue.message}`);
|
|
245
|
+
}
|
|
246
|
+
if (envIssues.length > 5) {
|
|
247
|
+
console.log(` ${c.dim}... and ${envIssues.length - 5} more${c.reset}`);
|
|
248
|
+
}
|
|
249
|
+
// Probe 3: Network rules
|
|
250
|
+
const netIssues = result.issues.filter(i => i.message.includes('network restriction'));
|
|
251
|
+
const netStatus = netIssues.length === 0
|
|
252
|
+
? `${c.green}PASS${c.reset}`
|
|
253
|
+
: `${c.yellow}WARN${c.reset}`;
|
|
254
|
+
console.log(` ${pad('Network restrictions', labelW)} ${netStatus}`);
|
|
255
|
+
for (const issue of netIssues) {
|
|
256
|
+
console.log(` ${c.yellow}⚠${c.reset} ${issue.message}`);
|
|
257
|
+
}
|
|
258
|
+
// Probe 4: MCP permissions
|
|
259
|
+
const mcpIssues = result.issues.filter(i => i.message.includes('MCP'));
|
|
260
|
+
const mcpInfoIssues = result.issues.filter(i => i.message.includes('.claude/settings.json'));
|
|
261
|
+
const mcpStatus = mcpIssues.length > 0
|
|
262
|
+
? `${c.red}FAIL (${mcpIssues.length})${c.reset}`
|
|
263
|
+
: mcpInfoIssues.length > 0
|
|
264
|
+
? `${c.dim}N/A${c.reset}`
|
|
265
|
+
: `${c.green}PASS${c.reset}`;
|
|
266
|
+
console.log(` ${pad('MCP permissions', labelW)} ${mcpStatus}`);
|
|
267
|
+
for (const issue of mcpIssues) {
|
|
268
|
+
console.log(` ${c.red}✗${c.reset} ${issue.message}`);
|
|
269
|
+
}
|
|
270
|
+
console.log(` ${c.dim}${'─'.repeat(labelW + statusW + 6)}${c.reset}`);
|
|
271
|
+
// Score
|
|
272
|
+
const scoreColor = sandboxScore >= 7 ? c.green : sandboxScore >= 4 ? c.yellow : c.red;
|
|
273
|
+
console.log(`\n blast radius score ${scoreColor}${sandboxScore.toFixed(1)}/10${c.reset}`);
|
|
274
|
+
console.log(` if compromised ${blastRadiusLabel(sandboxScore)}\n`);
|
|
275
|
+
}
|
|
276
|
+
// ── String helpers ───────────────────────────────────────────────────────────
|
|
277
|
+
function pad(s, w) {
|
|
278
|
+
const clean = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
279
|
+
return s + ' '.repeat(Math.max(0, w - clean.length));
|
|
280
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { cachedReadFile as cachedRead } from '../file-cache.js';
|
|
4
|
+
const SOURCE_PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
id: 'eval',
|
|
7
|
+
regex: /\beval\s*\(/,
|
|
8
|
+
severity: 'error',
|
|
9
|
+
message: 'eval() usage — arbitrary code execution risk',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: 'exec-sync',
|
|
13
|
+
regex: /\bexecSync\s*\(|\bexecFileSync\s*\(/,
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
message: 'execSync/execFileSync — synchronous shell execution, injection risk if user input flows in',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'child-process-exec',
|
|
19
|
+
regex: /\brequire\s*\(\s*['"]child_process['"]\s*\)/,
|
|
20
|
+
severity: 'warning',
|
|
21
|
+
message: 'child_process require — verify no untrusted input reaches shell commands',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'function-constructor',
|
|
25
|
+
regex: /new\s+Function\s*\(/,
|
|
26
|
+
severity: 'error',
|
|
27
|
+
message: 'new Function() — dynamic code generation, equivalent to eval()',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'innerhtml',
|
|
31
|
+
regex: /\.innerHTML\s*=|dangerouslySetInnerHTML/,
|
|
32
|
+
severity: 'warning',
|
|
33
|
+
message: 'innerHTML/dangerouslySetInnerHTML — XSS risk if content is not sanitized',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'hardcoded-jwt',
|
|
37
|
+
regex: /['"]eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
|
|
38
|
+
severity: 'error',
|
|
39
|
+
message: 'hardcoded JWT token detected',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'hardcoded-private-key',
|
|
43
|
+
regex: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
|
|
44
|
+
severity: 'error',
|
|
45
|
+
message: 'hardcoded private key detected',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'disable-tls',
|
|
49
|
+
regex: /NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|rejectUnauthorized\s*:\s*false/,
|
|
50
|
+
severity: 'error',
|
|
51
|
+
message: 'TLS verification disabled — man-in-the-middle risk',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'sql-concat',
|
|
55
|
+
regex: /(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE)\s+.*\$\{|(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE)\s+.*\+\s*(?:req\.|params\.|query\.|body\.)/i,
|
|
56
|
+
severity: 'error',
|
|
57
|
+
message: 'SQL query string concatenation — SQL injection risk',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
// ── Source file collection ────────────────────────────────────────────────────
|
|
61
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
62
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'vendor', '__pycache__']);
|
|
63
|
+
const MAX_FILES = 500;
|
|
64
|
+
const MAX_FILE_SIZE = 512 * 1024; // 512KB
|
|
65
|
+
function collectSourceFiles(cwd, maxFiles = MAX_FILES) {
|
|
66
|
+
const files = [];
|
|
67
|
+
function walk(dir, depth) {
|
|
68
|
+
if (depth > 8 || files.length >= maxFiles)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
72
|
+
if (files.length >= maxFiles)
|
|
73
|
+
break;
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
|
|
76
|
+
continue;
|
|
77
|
+
walk(join(dir, entry.name), depth + 1);
|
|
78
|
+
}
|
|
79
|
+
else if (entry.isFile()) {
|
|
80
|
+
const ext = entry.name.slice(entry.name.lastIndexOf('.'));
|
|
81
|
+
if (SOURCE_EXTENSIONS.has(ext)) {
|
|
82
|
+
const full = join(dir, entry.name);
|
|
83
|
+
try {
|
|
84
|
+
if (statSync(full).size <= MAX_FILE_SIZE) {
|
|
85
|
+
files.push(full);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch { /* skip */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { /* skip */ }
|
|
94
|
+
}
|
|
95
|
+
walk(cwd, 0);
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
99
|
+
export function checkSourceSecurity(cwd) {
|
|
100
|
+
const files = collectSourceFiles(cwd);
|
|
101
|
+
const issues = [];
|
|
102
|
+
for (const filePath of files) {
|
|
103
|
+
try {
|
|
104
|
+
const content = cachedRead(filePath);
|
|
105
|
+
if (!content)
|
|
106
|
+
continue;
|
|
107
|
+
const relPath = relative(cwd, filePath);
|
|
108
|
+
const lines = content.split('\n');
|
|
109
|
+
for (let i = 0; i < lines.length; i++) {
|
|
110
|
+
const line = lines[i];
|
|
111
|
+
// Skip comments
|
|
112
|
+
const trimmed = line.trim();
|
|
113
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*'))
|
|
114
|
+
continue;
|
|
115
|
+
// Skip test files for some patterns
|
|
116
|
+
const isTest = relPath.includes('.test.') || relPath.includes('.spec.') || relPath.includes('__tests__');
|
|
117
|
+
for (const pattern of SOURCE_PATTERNS) {
|
|
118
|
+
// execSync in util files and non-test is fine for CLI tools — only flag in src/
|
|
119
|
+
if (pattern.id === 'exec-sync' && !relPath.startsWith('src/'))
|
|
120
|
+
continue;
|
|
121
|
+
// Skip innerHTML in test files
|
|
122
|
+
if (pattern.id === 'innerhtml' && isTest)
|
|
123
|
+
continue;
|
|
124
|
+
if (pattern.regex.test(line)) {
|
|
125
|
+
pattern.regex.lastIndex = 0;
|
|
126
|
+
issues.push({
|
|
127
|
+
severity: pattern.severity,
|
|
128
|
+
message: pattern.message,
|
|
129
|
+
file: relPath,
|
|
130
|
+
line: i + 1,
|
|
131
|
+
fixable: false,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch { /* skip */ }
|
|
138
|
+
}
|
|
139
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
140
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
141
|
+
const score = Math.max(0, 100 - errors * 30 - warnings * 10);
|
|
142
|
+
return {
|
|
143
|
+
name: 'source-security',
|
|
144
|
+
score,
|
|
145
|
+
maxScore: 100,
|
|
146
|
+
issues,
|
|
147
|
+
summary: files.length === 0
|
|
148
|
+
? 'no source files found'
|
|
149
|
+
: issues.length === 0
|
|
150
|
+
? `${files.length} source files scanned, clean`
|
|
151
|
+
: `${issues.length} security finding${issues.length !== 1 ? 's' : ''} in source code`,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { isGitRepo, readFile, c } from './util.js';
|
|
4
|
+
import { isGitRepo, readFile, c, gitExec } from './util.js';
|
|
5
5
|
import { checkReady } from './checks/ready.js';
|
|
6
6
|
import { checkDiff } from './checks/diff.js';
|
|
7
7
|
import { checkModels } from './checks/models.js';
|
|
@@ -30,11 +30,14 @@ import { checkSubsidy, runSubsidyCommand } from './checks/subsidy.js';
|
|
|
30
30
|
import { checkLoop, runLoopCommand } from './checks/loop.js';
|
|
31
31
|
import { checkBloat, runBloatCommand } from './checks/bloat.js';
|
|
32
32
|
import { checkGuard, runGuardCommand } from './checks/guard.js';
|
|
33
|
+
import { checkSandbox, runSandboxCommand } from './checks/sandbox.js';
|
|
33
34
|
import { checkExplain, runExplainCommand } from './checks/explain.js';
|
|
34
35
|
import { checkContext, runContextCommand } from './checks/context.js';
|
|
35
36
|
import { checkSplit, runSplitCommand } from './checks/split.js';
|
|
37
|
+
import { checkSourceSecurity } from './checks/source-security.js';
|
|
36
38
|
import { checkCompleteness } from './checks/completeness.js';
|
|
37
39
|
import { score } from './scorer.js';
|
|
40
|
+
import { toGrade } from './categories.js';
|
|
38
41
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
39
42
|
import { clearCache } from './file-cache.js';
|
|
40
43
|
const args = process.argv.slice(2);
|
|
@@ -90,6 +93,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
90
93
|
npx @safetnsr/vet explain [--since REF] [--verbose] [--json] risk-tier agent changes
|
|
91
94
|
npx @safetnsr/vet context [dir] audit agent context files for token cost + stale sections
|
|
92
95
|
npx @safetnsr/vet split [--since HEAD~1] [--apply] [--force] [--json] split AI mega-commits into atomic commits
|
|
96
|
+
npx @safetnsr/vet sandbox [dir] score agent runtime blast radius
|
|
93
97
|
|
|
94
98
|
${c.dim}categories:${c.reset}
|
|
95
99
|
security (30%) scan, secrets, config, model usage
|
|
@@ -105,6 +109,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
105
109
|
--hook pre-commit hook mode (exit 1 if below grade C)
|
|
106
110
|
--badge print markdown badge string and exit
|
|
107
111
|
--fix auto-fix configs, models
|
|
112
|
+
--diff-only only score files changed in current branch (great for CI)
|
|
108
113
|
--since REF diff against specific commit/range
|
|
109
114
|
--watch re-run on file changes
|
|
110
115
|
--json JSON output
|
|
@@ -125,7 +130,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
125
130
|
}
|
|
126
131
|
process.exit(0);
|
|
127
132
|
}
|
|
128
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split'];
|
|
133
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox'];
|
|
129
134
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
130
135
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
131
136
|
const isCI = flags.has('--ci');
|
|
@@ -133,6 +138,7 @@ const isHook = flags.has('--hook');
|
|
|
133
138
|
const isFix = flags.has('--fix');
|
|
134
139
|
const isWatch = flags.has('--watch');
|
|
135
140
|
const isBadge = flags.has('--badge');
|
|
141
|
+
const isDiffOnly = flags.has('--diff-only');
|
|
136
142
|
const isJSON = flags.has('--json') || (!process.stdout.isTTY && !flags.has('--pretty') && !isBadge);
|
|
137
143
|
const since = flagMap.get('since');
|
|
138
144
|
const maxFiles = flagMap.has('max-files') ? (parseInt(flagMap.get('max-files'), 10) || 0) : 0;
|
|
@@ -302,6 +308,16 @@ if (command === 'explain') {
|
|
|
302
308
|
}
|
|
303
309
|
process.exit(0);
|
|
304
310
|
}
|
|
311
|
+
if (command === 'sandbox') {
|
|
312
|
+
try {
|
|
313
|
+
await runSandboxCommand(cwd, flags);
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
console.error(`${c.red}sandbox failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
process.exit(0);
|
|
320
|
+
}
|
|
305
321
|
if (!isGitRepo(cwd)) {
|
|
306
322
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
307
323
|
process.exit(1);
|
|
@@ -339,6 +355,56 @@ async function withTimeout(name, fn, timeoutMs = 30_000) {
|
|
|
339
355
|
Promise.resolve(fn()).then((r) => { clearTimeout(timer); res(r); }).catch(() => { clearTimeout(timer); res({ name, score: 100, maxScore: 100, issues: [], summary: 'check failed' }); });
|
|
340
356
|
});
|
|
341
357
|
}
|
|
358
|
+
/** Get files changed vs main/master branch or --since ref */
|
|
359
|
+
function getChangedFiles(cwd, sinceRef) {
|
|
360
|
+
const base = sinceRef || (() => {
|
|
361
|
+
const main = gitExec(['merge-base', 'HEAD', 'origin/main'], cwd);
|
|
362
|
+
if (main)
|
|
363
|
+
return main;
|
|
364
|
+
const master = gitExec(['merge-base', 'HEAD', 'origin/master'], cwd);
|
|
365
|
+
if (master)
|
|
366
|
+
return master;
|
|
367
|
+
return 'HEAD~1';
|
|
368
|
+
})();
|
|
369
|
+
const output = gitExec(['diff', '--name-only', base, 'HEAD'], cwd);
|
|
370
|
+
if (!output)
|
|
371
|
+
return new Set();
|
|
372
|
+
return new Set(output.split('\n').filter(Boolean));
|
|
373
|
+
}
|
|
374
|
+
/** Filter VetResult to only include issues from changed files, then re-score */
|
|
375
|
+
function filterDiffOnly(result, changedFiles) {
|
|
376
|
+
if (changedFiles.size === 0)
|
|
377
|
+
return result;
|
|
378
|
+
const filtered = {
|
|
379
|
+
...result,
|
|
380
|
+
categories: result.categories.map(cat => {
|
|
381
|
+
const filteredChecks = cat.checks.map(check => {
|
|
382
|
+
const filteredIssues = check.issues.filter(issue => !issue.file || changedFiles.has(issue.file));
|
|
383
|
+
const penalty = filteredIssues.reduce((sum, i) => {
|
|
384
|
+
if (i.severity === 'error')
|
|
385
|
+
return sum + 25;
|
|
386
|
+
if (i.severity === 'warning')
|
|
387
|
+
return sum + 10;
|
|
388
|
+
return sum + 2;
|
|
389
|
+
}, 0);
|
|
390
|
+
const newScore = Math.max(0, 100 - penalty);
|
|
391
|
+
return { ...check, score: newScore, issues: filteredIssues };
|
|
392
|
+
});
|
|
393
|
+
const allIssues = filteredChecks.flatMap(c => c.issues);
|
|
394
|
+
const checksWithIssues = filteredChecks.filter(c => c.issues.length > 0);
|
|
395
|
+
const avgScore = checksWithIssues.length > 0
|
|
396
|
+
? Math.round(checksWithIssues.reduce((sum, c) => sum + c.score, 0) / checksWithIssues.length)
|
|
397
|
+
: 100;
|
|
398
|
+
return { ...cat, checks: filteredChecks, issues: allIssues, score: avgScore };
|
|
399
|
+
}),
|
|
400
|
+
};
|
|
401
|
+
const totalWeight = filtered.categories.reduce((sum, c) => sum + c.weight, 0);
|
|
402
|
+
filtered.score = Math.round(filtered.categories.reduce((sum, c) => sum + c.score * c.weight, 0) / totalWeight);
|
|
403
|
+
filtered.grade = toGrade(filtered.score);
|
|
404
|
+
filtered.totalIssues = filtered.categories.reduce((sum, c) => c.issues.length + sum, 0);
|
|
405
|
+
filtered.fixableIssues = filtered.categories.reduce((sum, c) => c.issues.filter(i => i.fixable).length + sum, 0);
|
|
406
|
+
return filtered;
|
|
407
|
+
}
|
|
342
408
|
async function runChecks() {
|
|
343
409
|
const globalStart = Date.now();
|
|
344
410
|
const GLOBAL_TIMEOUT = 120_000;
|
|
@@ -353,8 +419,9 @@ async function runChecks() {
|
|
|
353
419
|
}
|
|
354
420
|
}
|
|
355
421
|
// Run ALL independent checks in parallel
|
|
356
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult, splitResult,] = await Promise.all([
|
|
422
|
+
const [scanResult, sourceSecurityResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult, splitResult, sandboxResult,] = await Promise.all([
|
|
357
423
|
withTimeout('scan', () => checkScan(cwd)),
|
|
424
|
+
withTimeout('source-security', () => checkSourceSecurity(cwd)),
|
|
358
425
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
359
426
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
360
427
|
withTimeout('models', () => checkModels(cwd, ignore)),
|
|
@@ -383,6 +450,7 @@ async function runChecks() {
|
|
|
383
450
|
withTimeout('clones', () => checkClones(cwd), 60_000),
|
|
384
451
|
withTimeout('context', () => checkContext(cwd)),
|
|
385
452
|
withTimeout('split', () => checkSplit(cwd)),
|
|
453
|
+
withTimeout('sandbox', () => checkSandbox(cwd)),
|
|
386
454
|
]);
|
|
387
455
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
388
456
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -392,7 +460,7 @@ async function runChecks() {
|
|
|
392
460
|
// Clear file cache after all checks complete
|
|
393
461
|
clearCache();
|
|
394
462
|
return score(cwd, {
|
|
395
|
-
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
|
|
463
|
+
security: [scanResult, sourceSecurityResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult, sandboxResult],
|
|
396
464
|
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, explainResult],
|
|
397
465
|
debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult, splitResult],
|
|
398
466
|
deps: [depsResult],
|
|
@@ -454,7 +522,11 @@ if (isWatch) {
|
|
|
454
522
|
else {
|
|
455
523
|
// Normal run
|
|
456
524
|
try {
|
|
457
|
-
|
|
525
|
+
let result = await runChecks();
|
|
526
|
+
if (isDiffOnly) {
|
|
527
|
+
const changedFiles = getChangedFiles(cwd, since);
|
|
528
|
+
result = filterDiffOnly(result, changedFiles);
|
|
529
|
+
}
|
|
458
530
|
if (isJSON) {
|
|
459
531
|
console.log(reportJSON(result));
|
|
460
532
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function fetchData(url: any): Promise<any>;
|
|
2
|
+
export declare function processItems(items: any): any[];
|
|
3
|
+
export declare const exec: any;
|
|
4
|
+
export declare function runCmd(cmd: string): any;
|
|
5
|
+
export declare function deepClone(obj: any): any;
|
|
6
|
+
export declare function dangerousEval(code: string): any;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// TODO: clean this up eventually
|
|
2
|
+
// HACK: workaround for broken API
|
|
3
|
+
const API_KEY = "sk-proj-abc123secretkey456def789";
|
|
4
|
+
const DB_PASSWORD = "admin123!@#";
|
|
5
|
+
export async function fetchData(url) {
|
|
6
|
+
// @ts-ignore
|
|
7
|
+
const res = await fetch(url);
|
|
8
|
+
const data = await res.json();
|
|
9
|
+
try {
|
|
10
|
+
return data;
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
// swallow error silently
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function processItems(items) {
|
|
17
|
+
var result = [];
|
|
18
|
+
for (var i = 0; i < items.length; i++) {
|
|
19
|
+
for (var j = 0; j < items[i].children.length; j++) {
|
|
20
|
+
for (var k = 0; k < items[i].children[j].values.length; k++) {
|
|
21
|
+
if (items[i].children[j].values[k] !== null && items[i].children[j].values[k] !== undefined) {
|
|
22
|
+
result.push(items[i].children[j].values[k]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
export const exec = require('child_process').execSync;
|
|
30
|
+
export function runCmd(cmd) {
|
|
31
|
+
return exec(cmd).toString();
|
|
32
|
+
}
|
|
33
|
+
// copied from stackoverflow
|
|
34
|
+
export function deepClone(obj) {
|
|
35
|
+
return JSON.parse(JSON.stringify(obj));
|
|
36
|
+
}
|
|
37
|
+
export function dangerousEval(code) {
|
|
38
|
+
return eval(code);
|
|
39
|
+
}
|