@safetnsr/vet 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -2
- package/dist/checks/edge.d.ts +30 -0
- package/dist/checks/edge.js +230 -0
- package/dist/checks/models.d.ts +1 -1
- package/dist/checks/models.js +49 -16
- package/dist/checks/ready.d.ts +1 -1
- package/dist/checks/ready.js +48 -9
- package/dist/checks/receipt.d.ts +48 -0
- package/dist/checks/receipt.js +306 -0
- package/dist/checks/scan.d.ts +2 -0
- package/dist/checks/scan.js +225 -0
- package/dist/checks/secrets.d.ts +2 -0
- package/dist/checks/secrets.js +280 -0
- package/dist/cli.js +48 -10
- package/package.json +6 -2
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as crypto from 'node:crypto';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
// ── Session file discovery ───────────────────────────────────────────────────
|
|
6
|
+
function walkDir(dir) {
|
|
7
|
+
const results = [];
|
|
8
|
+
try {
|
|
9
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const full = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory())
|
|
13
|
+
results.push(...walkDir(full));
|
|
14
|
+
else
|
|
15
|
+
results.push(full);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch { /* skip */ }
|
|
19
|
+
return results;
|
|
20
|
+
}
|
|
21
|
+
export function findSessionFiles(baseDir) {
|
|
22
|
+
const dir = baseDir || path.join(process.env['HOME'] || '~', '.claude', 'projects');
|
|
23
|
+
if (!fs.existsSync(dir))
|
|
24
|
+
return [];
|
|
25
|
+
return walkDir(dir).filter(f => f.endsWith('.jsonl')).sort();
|
|
26
|
+
}
|
|
27
|
+
export function findLatestSession(baseDir) {
|
|
28
|
+
const files = findSessionFiles(baseDir);
|
|
29
|
+
if (files.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
let latest = files[0];
|
|
32
|
+
let latestMtime = fs.statSync(latest).mtimeMs;
|
|
33
|
+
for (let i = 1; i < files.length; i++) {
|
|
34
|
+
const mtime = fs.statSync(files[i]).mtimeMs;
|
|
35
|
+
if (mtime > latestMtime) {
|
|
36
|
+
latest = files[i];
|
|
37
|
+
latestMtime = mtime;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return latest;
|
|
41
|
+
}
|
|
42
|
+
// ── JSONL parsing ────────────────────────────────────────────────────────────
|
|
43
|
+
export async function parseSessionFile(filePath) {
|
|
44
|
+
const toolUses = [];
|
|
45
|
+
const entries = [];
|
|
46
|
+
const rl = createInterface({ input: fs.createReadStream(filePath, { encoding: 'utf-8' }), crlfDelay: Infinity });
|
|
47
|
+
for await (const line of rl) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed)
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
const entry = JSON.parse(trimmed);
|
|
53
|
+
entries.push(entry);
|
|
54
|
+
for (const content of [entry.content, entry.message?.content]) {
|
|
55
|
+
if (Array.isArray(content)) {
|
|
56
|
+
for (const block of content) {
|
|
57
|
+
if (block && typeof block === 'object' && block.type === 'tool_use') {
|
|
58
|
+
toolUses.push(block);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* skip malformed */ }
|
|
65
|
+
}
|
|
66
|
+
return { toolUses, entries };
|
|
67
|
+
}
|
|
68
|
+
// ── Action grouper ───────────────────────────────────────────────────────────
|
|
69
|
+
function getFilePath(input) {
|
|
70
|
+
const p = (input['file_path'] || input['path'] || input['filePath'] || input['filename'] || '');
|
|
71
|
+
return p || null;
|
|
72
|
+
}
|
|
73
|
+
function extractPackages(cmd, packages) {
|
|
74
|
+
const npmM = cmd.match(/npm\s+(?:install|i|add)\s+([^\s&|;]+(?:\s+[^\s&|;-][^\s&|;]*)*)/);
|
|
75
|
+
if (npmM)
|
|
76
|
+
npmM[1].split(/\s+/).filter(p => !p.startsWith('-')).forEach(p => packages.add(p));
|
|
77
|
+
const yarnM = cmd.match(/yarn\s+add\s+([^\s&|;]+(?:\s+[^\s&|;-][^\s&|;]*)*)/);
|
|
78
|
+
if (yarnM)
|
|
79
|
+
yarnM[1].split(/\s+/).filter(p => !p.startsWith('-')).forEach(p => packages.add(p));
|
|
80
|
+
const pipM = cmd.match(/pip3?\s+install\s+([^\s&|;]+(?:\s+[^\s&|;-][^\s&|;]*)*)/);
|
|
81
|
+
if (pipM)
|
|
82
|
+
pipM[1].split(/\s+/).filter(p => !p.startsWith('-')).forEach(p => packages.add(p));
|
|
83
|
+
}
|
|
84
|
+
function extractUrls(cmd, urls) {
|
|
85
|
+
const matches = cmd.match(/https?:\/\/[^\s"'`<>|&;]+/g);
|
|
86
|
+
if (matches)
|
|
87
|
+
matches.forEach(u => urls.add(u));
|
|
88
|
+
}
|
|
89
|
+
function extractFileOps(cmd, deleted, created) {
|
|
90
|
+
const rmM = cmd.match(/(?:rm|trash)\s+(?:-[rf]*\s+)*([^\s&|;]+)/);
|
|
91
|
+
if (rmM && /^(?:rm|trash)\b/.test(cmd))
|
|
92
|
+
deleted.add(rmM[1]);
|
|
93
|
+
const touchM = cmd.match(/(?:touch|mkdir)\s+(?:-[p]*\s+)*([^\s&|;]+)/);
|
|
94
|
+
if (touchM)
|
|
95
|
+
created.add(touchM[1]);
|
|
96
|
+
}
|
|
97
|
+
function calculateDuration(start, end) {
|
|
98
|
+
if (!start || !end)
|
|
99
|
+
return null;
|
|
100
|
+
try {
|
|
101
|
+
const diff = new Date(end).getTime() - new Date(start).getTime();
|
|
102
|
+
return isNaN(diff) ? null : Math.round(diff / 1000);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function groupActions(toolUses, entries, sessionPath) {
|
|
109
|
+
const filesModified = new Set();
|
|
110
|
+
const filesDeleted = new Set();
|
|
111
|
+
const filesCreated = new Set();
|
|
112
|
+
const commandsRun = [];
|
|
113
|
+
const urlsFetched = new Set();
|
|
114
|
+
const packagesInstalled = new Set();
|
|
115
|
+
const timestamps = entries.map(e => e.timestamp).filter((t) => typeof t === 'string').sort();
|
|
116
|
+
for (const tool of toolUses) {
|
|
117
|
+
const input = tool.input || {};
|
|
118
|
+
switch (tool.name) {
|
|
119
|
+
case 'Write':
|
|
120
|
+
case 'write':
|
|
121
|
+
case 'write_file':
|
|
122
|
+
case 'create_file': {
|
|
123
|
+
const fp = getFilePath(input);
|
|
124
|
+
if (fp)
|
|
125
|
+
filesCreated.add(fp);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'Edit':
|
|
129
|
+
case 'edit':
|
|
130
|
+
case 'edit_file':
|
|
131
|
+
case 'str_replace_editor': {
|
|
132
|
+
const fp = getFilePath(input);
|
|
133
|
+
if (fp)
|
|
134
|
+
filesModified.add(fp);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'Read':
|
|
138
|
+
case 'read':
|
|
139
|
+
case 'read_file': break;
|
|
140
|
+
case 'exec':
|
|
141
|
+
case 'execute':
|
|
142
|
+
case 'bash':
|
|
143
|
+
case 'terminal':
|
|
144
|
+
case 'run_command': {
|
|
145
|
+
const cmd = (input['command'] || input['cmd'] || '');
|
|
146
|
+
if (cmd) {
|
|
147
|
+
commandsRun.push(cmd);
|
|
148
|
+
extractPackages(cmd, packagesInstalled);
|
|
149
|
+
extractUrls(cmd, urlsFetched);
|
|
150
|
+
extractFileOps(cmd, filesDeleted, filesCreated);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'web_fetch':
|
|
155
|
+
case 'fetch':
|
|
156
|
+
case 'http_request':
|
|
157
|
+
case 'web_search': {
|
|
158
|
+
const url = (input['url'] || input['query'] || '');
|
|
159
|
+
if (url)
|
|
160
|
+
urlsFetched.add(url);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case 'browser': {
|
|
164
|
+
const url = (input['targetUrl'] || input['url'] || '');
|
|
165
|
+
if (url)
|
|
166
|
+
urlsFetched.add(url);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const f of filesCreated)
|
|
172
|
+
filesModified.delete(f);
|
|
173
|
+
return {
|
|
174
|
+
session_id: path.basename(sessionPath, '.jsonl'),
|
|
175
|
+
start_time: timestamps[0] || null,
|
|
176
|
+
end_time: timestamps[timestamps.length - 1] || null,
|
|
177
|
+
duration_seconds: calculateDuration(timestamps[0], timestamps[timestamps.length - 1]),
|
|
178
|
+
files_modified: [...filesModified].sort(),
|
|
179
|
+
files_deleted: [...filesDeleted].sort(),
|
|
180
|
+
files_created: [...filesCreated].sort(),
|
|
181
|
+
commands_run: commandsRun,
|
|
182
|
+
urls_fetched: [...urlsFetched].sort(),
|
|
183
|
+
packages_installed: [...packagesInstalled].sort(),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// ── SHA256 + rendering ───────────────────────────────────────────────────────
|
|
187
|
+
export function computeSha256(content) {
|
|
188
|
+
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
189
|
+
}
|
|
190
|
+
export function renderReceiptText(actions) {
|
|
191
|
+
const WIDTH = 46;
|
|
192
|
+
const BORDER = '═'.repeat(WIDTH);
|
|
193
|
+
const pad = (s) => (s.length >= WIDTH - 2 ? s.slice(0, WIDTH - 2) : s + ' '.repeat(WIDTH - 2 - s.length));
|
|
194
|
+
const line = (s) => `║ ${pad(s)} ║`;
|
|
195
|
+
const fmt = (d) => d ? new Date(d).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : 'unknown';
|
|
196
|
+
const dur = (s) => s === null ? 'unknown' : s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
197
|
+
const lines = [];
|
|
198
|
+
lines.push(`╔${BORDER}╗`);
|
|
199
|
+
const title = 'AGENT SESSION RECEIPT';
|
|
200
|
+
const leftPad = Math.floor((WIDTH - 2 - title.length) / 2);
|
|
201
|
+
const rightPad = WIDTH - 2 - title.length - leftPad;
|
|
202
|
+
lines.push(`║ ${' '.repeat(leftPad)}${title}${' '.repeat(rightPad)} ║`);
|
|
203
|
+
lines.push(`╠${BORDER}╣`);
|
|
204
|
+
lines.push(line(`Session: ${actions.session_id.slice(0, 30)}`));
|
|
205
|
+
lines.push(line(`Date: ${fmt(actions.start_time)}`));
|
|
206
|
+
lines.push(line(`Duration: ${dur(actions.duration_seconds)}`));
|
|
207
|
+
lines.push(`╠${BORDER}╣`);
|
|
208
|
+
const section = (title, items) => {
|
|
209
|
+
lines.push(line(`${title} (${items.length})`));
|
|
210
|
+
if (items.length === 0)
|
|
211
|
+
lines.push(line(' (none)'));
|
|
212
|
+
else
|
|
213
|
+
for (const item of items)
|
|
214
|
+
lines.push(line(` ${item.slice(0, WIDTH - 6)}`));
|
|
215
|
+
lines.push(line(''));
|
|
216
|
+
};
|
|
217
|
+
section('FILES CREATED', actions.files_created);
|
|
218
|
+
section('FILES MODIFIED', actions.files_modified);
|
|
219
|
+
section('FILES DELETED', actions.files_deleted);
|
|
220
|
+
section('COMMANDS RUN', actions.commands_run);
|
|
221
|
+
section('URLS FETCHED', actions.urls_fetched);
|
|
222
|
+
section('PACKAGES INSTALLED', actions.packages_installed);
|
|
223
|
+
const body = lines.join('\n');
|
|
224
|
+
const sha256 = computeSha256(body);
|
|
225
|
+
lines.push(`╠${BORDER}╣`);
|
|
226
|
+
lines.push(line(`SHA256: ${sha256.slice(0, 36)}`));
|
|
227
|
+
lines.push(`╚${BORDER}╝`);
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
export function renderReceiptJson(actions) {
|
|
231
|
+
const body = JSON.stringify(actions, null, 2);
|
|
232
|
+
return { actions, sha256: computeSha256(body), generated_at: new Date().toISOString() };
|
|
233
|
+
}
|
|
234
|
+
// ── CheckResult adapter ──────────────────────────────────────────────────────
|
|
235
|
+
export async function checkReceipt(cwd) {
|
|
236
|
+
const sessionFile = findLatestSession();
|
|
237
|
+
const issues = [];
|
|
238
|
+
if (!sessionFile) {
|
|
239
|
+
return {
|
|
240
|
+
name: 'receipt',
|
|
241
|
+
score: 10,
|
|
242
|
+
maxScore: 10,
|
|
243
|
+
issues: [{ severity: 'info', message: 'no claude session files found (~/.claude/projects/)', fixable: false }],
|
|
244
|
+
summary: 'no session logs found',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
let toolUses = [];
|
|
248
|
+
let entries = [];
|
|
249
|
+
try {
|
|
250
|
+
const parsed = await parseSessionFile(sessionFile);
|
|
251
|
+
toolUses = parsed.toolUses;
|
|
252
|
+
entries = parsed.entries;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return {
|
|
256
|
+
name: 'receipt',
|
|
257
|
+
score: 10,
|
|
258
|
+
maxScore: 10,
|
|
259
|
+
issues: [{ severity: 'warning', message: 'could not parse session file', fixable: false }],
|
|
260
|
+
summary: 'session parse error',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const actions = groupActions(toolUses, entries, sessionFile);
|
|
264
|
+
// Audit observations
|
|
265
|
+
if (actions.commands_run.length > 20) {
|
|
266
|
+
issues.push({ severity: 'info', message: `${actions.commands_run.length} commands run in last session — high activity`, fixable: false });
|
|
267
|
+
}
|
|
268
|
+
if (actions.files_deleted.length > 0) {
|
|
269
|
+
issues.push({ severity: 'info', message: `${actions.files_deleted.length} file(s) deleted: ${actions.files_deleted.slice(0, 3).join(', ')}`, fixable: false });
|
|
270
|
+
}
|
|
271
|
+
if (actions.packages_installed.length > 0) {
|
|
272
|
+
issues.push({ severity: 'info', message: `packages installed: ${actions.packages_installed.join(', ')}`, fixable: false });
|
|
273
|
+
}
|
|
274
|
+
if (actions.urls_fetched.length > 5) {
|
|
275
|
+
issues.push({ severity: 'info', message: `${actions.urls_fetched.length} external URLs fetched`, fixable: false });
|
|
276
|
+
}
|
|
277
|
+
const totalActions = actions.files_created.length +
|
|
278
|
+
actions.files_modified.length +
|
|
279
|
+
actions.files_deleted.length +
|
|
280
|
+
actions.commands_run.length;
|
|
281
|
+
const sessionId = path.basename(sessionFile, '.jsonl').slice(0, 20);
|
|
282
|
+
return {
|
|
283
|
+
name: 'receipt',
|
|
284
|
+
score: 10, // Receipt is informational — always full score
|
|
285
|
+
maxScore: 10,
|
|
286
|
+
issues,
|
|
287
|
+
summary: `session ${sessionId}: ${totalActions} actions, ${actions.files_created.length} created, ${actions.files_modified.length} modified`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// ── Standalone subcommand output ─────────────────────────────────────────────
|
|
291
|
+
export async function runReceiptCommand(format = 'ascii') {
|
|
292
|
+
const sessionFile = findLatestSession();
|
|
293
|
+
if (!sessionFile) {
|
|
294
|
+
console.error('no claude session files found in ~/.claude/projects/');
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
const { toolUses, entries } = await parseSessionFile(sessionFile);
|
|
298
|
+
const actions = groupActions(toolUses, entries, sessionFile);
|
|
299
|
+
if (format === 'json') {
|
|
300
|
+
const receipt = renderReceiptJson(actions);
|
|
301
|
+
console.log(JSON.stringify(receipt, null, 2));
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
console.log(renderReceiptText(actions));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
3
|
+
const CRITICAL_PATTERNS = [
|
|
4
|
+
{
|
|
5
|
+
id: 'base64-url',
|
|
6
|
+
severity: 'critical',
|
|
7
|
+
description: 'Base64-encoded URL in config — potential exfiltration endpoint',
|
|
8
|
+
regex: /(?:aHR0c|data:text\/html;base64)/i,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: 'curl-wget',
|
|
12
|
+
severity: 'critical',
|
|
13
|
+
description: 'Network download command in agent config — potential remote payload fetch',
|
|
14
|
+
regex: /(?:curl|wget|fetch)\s+(?:https?:\/\/|[-])/i,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'shell-injection',
|
|
18
|
+
severity: 'critical',
|
|
19
|
+
description: 'Shell injection pattern — command substitution or eval/exec call',
|
|
20
|
+
regex: /\$\(|`[^`]+`|\beval\b|\bexec\b/,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'powershell-download',
|
|
24
|
+
severity: 'critical',
|
|
25
|
+
description: 'PowerShell download cradle — remote code execution pattern',
|
|
26
|
+
regex: /powershell.*downloadstring|iex.*webclient/i,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
const HIGH_PATTERNS = [
|
|
30
|
+
{
|
|
31
|
+
id: 'prompt-injection',
|
|
32
|
+
severity: 'high',
|
|
33
|
+
description: 'Prompt injection — instructs agent to ignore previous instructions',
|
|
34
|
+
regex: /ignore\s+(?:all\s+)?previous\s+instructions?/i,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'system-prompt-override',
|
|
38
|
+
severity: 'high',
|
|
39
|
+
description: 'Attempts to override or replace the system prompt',
|
|
40
|
+
regex: /system\s*prompt\s*(?:override|replace|ignore)/i,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'forget-prior',
|
|
44
|
+
severity: 'high',
|
|
45
|
+
description: 'Instructs agent to forget prior/previous context',
|
|
46
|
+
regex: /forget\s+(?:all\s+)?(?:prior|previous|earlier)/i,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'env-var-exfiltration',
|
|
50
|
+
severity: 'high',
|
|
51
|
+
description: 'Env var referenced in URL context — potential key exfiltration',
|
|
52
|
+
regex: /https?:\/\/[^\s"']*\$(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)/i,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'permission-escalation',
|
|
56
|
+
severity: 'high',
|
|
57
|
+
description: 'Attempts to escalate permissions — sudo, chmod 777, chown root',
|
|
58
|
+
regex: /sudo|chmod\s+777|chown\s+root/i,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'hidden-file-write',
|
|
62
|
+
severity: 'high',
|
|
63
|
+
description: 'Redirects output to hidden dotfiles or system directories',
|
|
64
|
+
regex: />\s*~\/\.|>\s*\/etc\//i,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
const INFO_PATTERNS = [
|
|
68
|
+
{
|
|
69
|
+
id: 'external-url',
|
|
70
|
+
severity: 'info',
|
|
71
|
+
description: 'External URL in config — often legitimate but worth reviewing',
|
|
72
|
+
regex: /https?:\/\/[^\s"']+/,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'sensitive-path',
|
|
76
|
+
severity: 'info',
|
|
77
|
+
description: 'Reference to sensitive path (.ssh, .aws, .env, .gnupg)',
|
|
78
|
+
regex: /\.ssh|\.gnupg|\.aws|\.env/,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
const ALL_SCAN_PATTERNS = [...CRITICAL_PATTERNS, ...HIGH_PATTERNS, ...INFO_PATTERNS];
|
|
82
|
+
// ── Config file targets ──────────────────────────────────────────────────────
|
|
83
|
+
const CONFIG_TARGETS = [
|
|
84
|
+
'.claude', 'CLAUDE.md', 'AGENTS.md',
|
|
85
|
+
'.cursorrules', '.cursor',
|
|
86
|
+
'.github',
|
|
87
|
+
'.aider.conf.yml',
|
|
88
|
+
'.continue',
|
|
89
|
+
'.mcp',
|
|
90
|
+
'.roomodes', '.roo',
|
|
91
|
+
];
|
|
92
|
+
// ── File helpers ─────────────────────────────────────────────────────────────
|
|
93
|
+
function isTextFile(filePath) {
|
|
94
|
+
try {
|
|
95
|
+
const buf = readFileSync(filePath);
|
|
96
|
+
const sampleSize = Math.min(512, buf.length);
|
|
97
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
98
|
+
if (buf[i] === 0)
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function collectDirFiles(dir) {
|
|
108
|
+
const files = [];
|
|
109
|
+
try {
|
|
110
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const full = join(dir, entry.name);
|
|
113
|
+
if (entry.isFile()) {
|
|
114
|
+
files.push(full);
|
|
115
|
+
}
|
|
116
|
+
else if (entry.isDirectory()) {
|
|
117
|
+
files.push(...collectDirFiles(full));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { /* skip */ }
|
|
122
|
+
return files;
|
|
123
|
+
}
|
|
124
|
+
function collectConfigFiles(cwd) {
|
|
125
|
+
const files = [];
|
|
126
|
+
for (const target of CONFIG_TARGETS) {
|
|
127
|
+
const full = join(cwd, target);
|
|
128
|
+
if (!existsSync(full))
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
const s = statSync(full);
|
|
132
|
+
if (s.isFile()) {
|
|
133
|
+
files.push(full);
|
|
134
|
+
}
|
|
135
|
+
else if (s.isDirectory()) {
|
|
136
|
+
files.push(...collectDirFiles(full));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch { /* skip */ }
|
|
140
|
+
}
|
|
141
|
+
// Copilot instructions
|
|
142
|
+
const copilot = join(cwd, '.github', 'copilot-instructions.md');
|
|
143
|
+
if (existsSync(copilot) && !files.includes(copilot)) {
|
|
144
|
+
files.push(copilot);
|
|
145
|
+
}
|
|
146
|
+
return [...new Set(files)];
|
|
147
|
+
}
|
|
148
|
+
function scanContent(content, relPath) {
|
|
149
|
+
const findings = [];
|
|
150
|
+
const lines = content.split('\n');
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
const line = lines[i];
|
|
153
|
+
for (const pattern of ALL_SCAN_PATTERNS) {
|
|
154
|
+
if (pattern.regex.test(line)) {
|
|
155
|
+
pattern.regex.lastIndex = 0;
|
|
156
|
+
findings.push({
|
|
157
|
+
file: relPath,
|
|
158
|
+
line: i + 1,
|
|
159
|
+
severity: pattern.severity,
|
|
160
|
+
description: pattern.description,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (pattern.regex.global)
|
|
164
|
+
pattern.regex.lastIndex = 0;
|
|
165
|
+
}
|
|
166
|
+
// Special: base64 that decodes to URL
|
|
167
|
+
const b64Matches = line.match(/[A-Za-z0-9+/]{20,}={0,2}/g);
|
|
168
|
+
if (b64Matches) {
|
|
169
|
+
for (const m of b64Matches) {
|
|
170
|
+
try {
|
|
171
|
+
const decoded = Buffer.from(m, 'base64').toString('utf-8');
|
|
172
|
+
if (/https?:\/\//i.test(decoded) && !/[^\x00-\x7F]/.test(decoded)) {
|
|
173
|
+
findings.push({
|
|
174
|
+
file: relPath,
|
|
175
|
+
line: i + 1,
|
|
176
|
+
severity: 'critical',
|
|
177
|
+
description: 'Base64 string decodes to HTTP URL — likely encoded exfiltration endpoint',
|
|
178
|
+
});
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { /* skip */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
// ── CheckResult adapter ──────────────────────────────────────────────────────
|
|
189
|
+
export function checkScan(cwd) {
|
|
190
|
+
const configFiles = collectConfigFiles(cwd);
|
|
191
|
+
const findings = [];
|
|
192
|
+
let filesScanned = 0;
|
|
193
|
+
for (const filePath of configFiles) {
|
|
194
|
+
if (!isTextFile(filePath))
|
|
195
|
+
continue;
|
|
196
|
+
try {
|
|
197
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
198
|
+
const relPath = relative(cwd, filePath);
|
|
199
|
+
filesScanned++;
|
|
200
|
+
findings.push(...scanContent(content, relPath));
|
|
201
|
+
}
|
|
202
|
+
catch { /* skip */ }
|
|
203
|
+
}
|
|
204
|
+
const issues = findings.map(f => ({
|
|
205
|
+
severity: f.severity === 'critical' ? 'error' : f.severity === 'high' ? 'warning' : 'info',
|
|
206
|
+
message: f.description,
|
|
207
|
+
file: f.file,
|
|
208
|
+
line: f.line,
|
|
209
|
+
fixable: false,
|
|
210
|
+
}));
|
|
211
|
+
const criticals = findings.filter(f => f.severity === 'critical').length;
|
|
212
|
+
const highs = findings.filter(f => f.severity === 'high').length;
|
|
213
|
+
const score = Math.max(0, Math.min(10, 10 - criticals * 4 - highs * 1.5));
|
|
214
|
+
return {
|
|
215
|
+
name: 'scan',
|
|
216
|
+
score: Math.round(score * 10) / 10,
|
|
217
|
+
maxScore: 10,
|
|
218
|
+
issues,
|
|
219
|
+
summary: filesScanned === 0
|
|
220
|
+
? 'no agent config files found'
|
|
221
|
+
: findings.length === 0
|
|
222
|
+
? `${filesScanned} config file${filesScanned !== 1 ? 's' : ''} scanned, clean`
|
|
223
|
+
: `${findings.length} finding${findings.length !== 1 ? 's' : ''} in ${filesScanned} config file${filesScanned !== 1 ? 's' : ''}`,
|
|
224
|
+
};
|
|
225
|
+
}
|