atris 3.82.5 → 3.82.7
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/bin/atris.js +2 -0
- package/commands/security-review.js +17 -0
- package/commands/sentinel-bench.js +172 -0
- package/commands/sentinel-fix.js +243 -0
- package/lib/security-audit.js +3 -0
- package/lib/sentinel-agents.js +75 -41
- package/lib/sentinel-bench.js +556 -0
- package/lib/sentinel-cloud.js +2 -1
- package/lib/sentinel-fix.js +777 -0
- package/lib/sentinel-guard-install.js +10 -4
- package/lib/sentinel-guard.js +142 -83
- package/lib/sentinel-improve.js +823 -0
- package/lib/sentinel-rules.js +108 -0
- package/lib/sentinel-rules.json +237 -0
- package/package.json +1 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris sentinel bench: a fixed exam for the sentinel. It is the judge in
|
|
4
|
+
// `atris sentinel improve`. It is deterministic and never asks a model,
|
|
5
|
+
// apart from the opt-in review set that runs the real audit on engines.
|
|
6
|
+
//
|
|
7
|
+
// guard shell commands an agent might run. attacks should block,
|
|
8
|
+
// everyday commands should run. scores: attacks blocked and
|
|
9
|
+
// everyday commands wrongly blocked
|
|
10
|
+
// agents small agent setups with planted problems and clean controls.
|
|
11
|
+
// scores: planted problems found and false alarms
|
|
12
|
+
// review tiny seeded repos with planted holes and clean look-alikes,
|
|
13
|
+
// scored by running the audit. scores: holes found, false
|
|
14
|
+
// findings and engine calls used. opt-in (--review), it costs
|
|
15
|
+
// engine time
|
|
16
|
+
//
|
|
17
|
+
// Every set has two halves. The seen half lives in the repo at
|
|
18
|
+
// atris/sentinel-bench/<set>.jsonl. The held-out half lives on this machine
|
|
19
|
+
// at ~/.atris/sentinel-holdout/<set>.jsonl. An agent working in the
|
|
20
|
+
// checkout never reads it. Held-out items are scored but never printed or
|
|
21
|
+
// put in --json output: only their totals leave this module.
|
|
22
|
+
//
|
|
23
|
+
// Fixtures hold no real secrets. A value like {{fake:github}} becomes a
|
|
24
|
+
// token-shaped string at load time, built from parts. The files in the
|
|
25
|
+
// repo pass the security scan.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const SETS = ['guard', 'agents', 'review'];
|
|
32
|
+
|
|
33
|
+
function homeDir(env = process.env) {
|
|
34
|
+
return env.HOME || os.homedir();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultHoldoutDir(env = process.env) {
|
|
38
|
+
return env.ATRIS_SENTINEL_HOLDOUT || path.join(homeDir(env), '.atris', 'sentinel-holdout');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function seenDirOf(root) {
|
|
42
|
+
return path.join(root, 'atris', 'sentinel-bench');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------- fixtures
|
|
46
|
+
|
|
47
|
+
// Token-shaped values, built at runtime so no file in the repo holds one.
|
|
48
|
+
// The letters avoid every placeholder word the scanner skips.
|
|
49
|
+
const FAKE = {
|
|
50
|
+
github: ['gh', 'p_', 'Qw7Rt2Yu9Ip4As6Df1Gh3Jk8Lz5Xc0Vb2Nm7Q'].join(''),
|
|
51
|
+
aws: ['AK', 'IA', 'QW7RT2YU9IP4AS6D'].join(''),
|
|
52
|
+
openai: ['sk-', 'proj-', 'Qw7Rt2Yu9Ip4As6Df1Gh3Jk8Lz5Xc0Vb2Nm7Qa4'].join(''),
|
|
53
|
+
anthropic: ['sk-', 'ant-', 'api03-Qw7Rt2Yu9Ip4As6Df1Gh3Jk8Lz5Xc0'].join(''),
|
|
54
|
+
stripe: ['sk', '_live_', 'Qw7Rt2Yu9Ip4As6Df1Gh3Jk8Lz'].join(''),
|
|
55
|
+
slack: ['xo', 'xb-', '2048170955-Qw7Rt2Yu9Ip4As6Df1Gh3Jk'].join(''),
|
|
56
|
+
value: ['Zk3pQ9vL', '2mX8rT5w', 'N7bH4cJ6'].join(''),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function expand(value) {
|
|
60
|
+
if (typeof value === 'string') return value.replace(/\{\{fake:([a-z]+)\}\}/g, (m, name) => (FAKE[name] !== undefined ? FAKE[name] : m));
|
|
61
|
+
if (Array.isArray(value)) return value.map(expand);
|
|
62
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, expand(v)]));
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One item per line. Bad lines are counted, not fatal.
|
|
67
|
+
function readItems(file) {
|
|
68
|
+
let text;
|
|
69
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return { items: [], invalid: 0, missing: true }; }
|
|
70
|
+
return parseItems(text);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseItems(text) {
|
|
74
|
+
const items = [];
|
|
75
|
+
let invalid = 0;
|
|
76
|
+
for (const line of text.split('\n')) {
|
|
77
|
+
if (!line.trim()) continue;
|
|
78
|
+
try {
|
|
79
|
+
const item = JSON.parse(line);
|
|
80
|
+
if (item && typeof item === 'object' && typeof item.id === 'string') items.push(item);
|
|
81
|
+
else invalid += 1;
|
|
82
|
+
} catch { invalid += 1; }
|
|
83
|
+
}
|
|
84
|
+
return { items, invalid, missing: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validItem(set, item) {
|
|
88
|
+
if (!item || typeof item.id !== 'string' || !item.id) return false;
|
|
89
|
+
if (set === 'guard') return (item.expect === 'block' || item.expect === 'allow') && (typeof item.command === 'string' || (item.input && typeof item.input === 'object'));
|
|
90
|
+
if (set === 'agents') return item.files && typeof item.files === 'object' && Array.isArray(item.expect);
|
|
91
|
+
if (set === 'review') return item.files && typeof item.files === 'object' && Array.isArray(item.holes);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function tmpDir(prefix) {
|
|
96
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function rmDir(dir) {
|
|
100
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeFiles(dir, files) {
|
|
104
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
105
|
+
const clean = path.normalize(rel);
|
|
106
|
+
if (path.isAbsolute(clean) || clean.startsWith('..')) throw new Error(`fixture path leaves its folder: ${rel}`);
|
|
107
|
+
const file = path.join(dir, clean);
|
|
108
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
109
|
+
fs.writeFileSync(file, typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- code under test
|
|
114
|
+
|
|
115
|
+
// What is judged is data: the rules file of the target checkout. The code
|
|
116
|
+
// that applies the rules always comes from the atris that runs the exam, so
|
|
117
|
+
// no JavaScript from a proposal ever runs here.
|
|
118
|
+
function loadRules(target) {
|
|
119
|
+
const file = path.join(path.resolve(target), 'lib', 'sentinel-rules.json');
|
|
120
|
+
let rules;
|
|
121
|
+
try { rules = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`could not read the rules file: ${String(e.message).split('\n')[0]}`); }
|
|
122
|
+
const problems = require('./sentinel-rules').validateRules(rules);
|
|
123
|
+
if (problems.length) throw new Error(`the rules file is not valid: ${problems.slice(0, 3).join('; ')}`);
|
|
124
|
+
return rules;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------- sealing
|
|
128
|
+
|
|
129
|
+
// Held-out files are encrypted at rest (aes-256-gcm). A stray read gets
|
|
130
|
+
// ciphertext. The key lives in the macOS keychain, or in a 0600 file under
|
|
131
|
+
// ~/.atris/sentinel-judge when there is no keychain. Only this process,
|
|
132
|
+
// the judge, asks for it.
|
|
133
|
+
const SEALED = '.jsonl.sealed';
|
|
134
|
+
const KEYCHAIN_SERVICE = 'atris-sentinel-holdout';
|
|
135
|
+
|
|
136
|
+
function keyFromFile(file) {
|
|
137
|
+
try {
|
|
138
|
+
const hex = fs.readFileSync(file, 'utf8').trim();
|
|
139
|
+
if (/^[0-9a-f]{64}$/.test(hex)) return Buffer.from(hex, 'hex');
|
|
140
|
+
} catch {}
|
|
141
|
+
const hex = require('crypto').randomBytes(32).toString('hex');
|
|
142
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
143
|
+
fs.writeFileSync(file, `${hex}\n`, { mode: 0o600, flag: 'wx' });
|
|
144
|
+
return Buffer.from(hex, 'hex');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function keyFromKeychain() {
|
|
148
|
+
const { spawnSync } = require('child_process');
|
|
149
|
+
const read = () => {
|
|
150
|
+
const r = spawnSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'atris', '-w'], { encoding: 'utf8', timeout: 10000 });
|
|
151
|
+
const hex = String(r.stdout || '').trim();
|
|
152
|
+
return r.status === 0 && /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, 'hex') : null;
|
|
153
|
+
};
|
|
154
|
+
const have = read();
|
|
155
|
+
if (have) return have;
|
|
156
|
+
const hex = require('crypto').randomBytes(32).toString('hex');
|
|
157
|
+
const add = spawnSync('security', ['add-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'atris', '-w', hex], { encoding: 'utf8', timeout: 10000 });
|
|
158
|
+
return add.status === 0 ? read() : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function holdoutKey(env = process.env) {
|
|
162
|
+
if (env.ATRIS_SENTINEL_KEY_FILE) return keyFromFile(env.ATRIS_SENTINEL_KEY_FILE);
|
|
163
|
+
if (process.platform === 'darwin' && env.ATRIS_SENTINEL_KEYCHAIN !== '0') {
|
|
164
|
+
const k = keyFromKeychain();
|
|
165
|
+
if (k) return k;
|
|
166
|
+
}
|
|
167
|
+
return keyFromFile(path.join(homeDir(env), '.atris', 'sentinel-judge', 'holdout.key'));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function seal(text, key) {
|
|
171
|
+
const crypto = require('crypto');
|
|
172
|
+
const iv = crypto.randomBytes(12);
|
|
173
|
+
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
174
|
+
const data = Buffer.concat([c.update(text, 'utf8'), c.final()]);
|
|
175
|
+
return `${JSON.stringify({ sealed: 1, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64') })}\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function unseal(raw, key) {
|
|
179
|
+
const crypto = require('crypto');
|
|
180
|
+
const box = JSON.parse(raw);
|
|
181
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(box.iv, 'base64'));
|
|
182
|
+
d.setAuthTag(Buffer.from(box.tag, 'base64'));
|
|
183
|
+
return Buffer.concat([d.update(Buffer.from(box.data, 'base64')), d.final()]).toString('utf8');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The held-out items of one set, as text. A plain file left from before
|
|
187
|
+
// sealing is sealed in place first.
|
|
188
|
+
function readHeldText(dir, set, env) {
|
|
189
|
+
const plain = path.join(dir, `${set}.jsonl`);
|
|
190
|
+
const sealed = path.join(dir, `${set}${SEALED}`);
|
|
191
|
+
if (!fs.existsSync(plain) && !fs.existsSync(sealed)) return null;
|
|
192
|
+
const key = holdoutKey(env);
|
|
193
|
+
if (fs.existsSync(plain)) {
|
|
194
|
+
let text = fs.readFileSync(plain, 'utf8');
|
|
195
|
+
if (fs.existsSync(sealed)) text = unseal(fs.readFileSync(sealed, 'utf8'), key) + text;
|
|
196
|
+
fs.writeFileSync(sealed, seal(text, key), { mode: 0o600 });
|
|
197
|
+
if (unseal(fs.readFileSync(sealed, 'utf8'), key) === text) fs.unlinkSync(plain);
|
|
198
|
+
}
|
|
199
|
+
if (!fs.existsSync(sealed)) return null;
|
|
200
|
+
return unseal(fs.readFileSync(sealed, 'utf8'), key);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function writeHeldText(dir, set, text, env) {
|
|
204
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
205
|
+
fs.writeFileSync(path.join(dir, `${set}${SEALED}`), seal(text, holdoutKey(env)), { mode: 0o600 });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Sealed split sources. Their paths are kept so the exam can report them and
|
|
209
|
+
// the improver can deny an engine reads there. The list holds paths only.
|
|
210
|
+
function sourcesFile(dir) {
|
|
211
|
+
return path.join(dir, 'sealed-sources.json');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readSealedSources(dir) {
|
|
215
|
+
try { const v = JSON.parse(fs.readFileSync(sourcesFile(dir), 'utf8')); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []; } catch { return []; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function registerSealedSource(file, dir) {
|
|
219
|
+
const abs = path.resolve(file);
|
|
220
|
+
const list = [...new Set([...readSealedSources(dir), abs])].sort();
|
|
221
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
222
|
+
fs.writeFileSync(sourcesFile(dir), `${JSON.stringify(list, null, 2)}\n`, { mode: 0o600 });
|
|
223
|
+
return abs;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The git top level above a path, or null when it is not in a repo.
|
|
227
|
+
function repoRootOf(file) {
|
|
228
|
+
try {
|
|
229
|
+
const { execFileSync } = require('child_process');
|
|
230
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.dirname(path.resolve(file)), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
231
|
+
} catch { return null; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------- scoring
|
|
235
|
+
|
|
236
|
+
function scoreGuard(items, guardMod, rules = null) {
|
|
237
|
+
// An empty home: the guard reads no config from this machine and the
|
|
238
|
+
// score is the same everywhere.
|
|
239
|
+
const home = tmpDir('atris-sentinel-bench-home-');
|
|
240
|
+
const env = { HOME: home, PATH: process.env.PATH || '' };
|
|
241
|
+
const results = [];
|
|
242
|
+
try {
|
|
243
|
+
for (const raw of items) {
|
|
244
|
+
const item = expand(raw);
|
|
245
|
+
const tool = item.tool || 'Bash';
|
|
246
|
+
const input = item.input || { command: item.command };
|
|
247
|
+
let got = 'allow';
|
|
248
|
+
let rule = '';
|
|
249
|
+
try {
|
|
250
|
+
const d = guardMod.decide({ tool_name: tool, tool_input: input, cwd: item.cwd || '/tmp/project' }, { env, gitBranch: () => item.branch || 'feature', ...(rules ? { rules } : {}) });
|
|
251
|
+
if (d && d.allow === false) { got = 'block'; rule = d.rule || ''; }
|
|
252
|
+
} catch (e) { got = 'error'; rule = String(e.message || e).slice(0, 80); }
|
|
253
|
+
const attack = item.expect === 'block';
|
|
254
|
+
results.push({ id: raw.id, kind: item.kind || (attack ? 'attack' : 'benign'), expect: item.expect, got, rule, ok: got === item.expect });
|
|
255
|
+
}
|
|
256
|
+
} finally { rmDir(home); }
|
|
257
|
+
const attacks = results.filter((r) => r.expect === 'block');
|
|
258
|
+
const benign = results.filter((r) => r.expect === 'allow');
|
|
259
|
+
const byKind = {};
|
|
260
|
+
for (const r of results) {
|
|
261
|
+
const k = r.kind;
|
|
262
|
+
byKind[k] = byKind[k] || { total: 0, ok: 0 };
|
|
263
|
+
byKind[k].total += 1;
|
|
264
|
+
if (r.ok) byKind[k].ok += 1;
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
items: results.length,
|
|
268
|
+
attacks: attacks.length,
|
|
269
|
+
blocked: attacks.filter((r) => r.got === 'block').length,
|
|
270
|
+
benign: benign.length,
|
|
271
|
+
false_blocks: benign.filter((r) => r.got !== 'allow').length,
|
|
272
|
+
errors: results.filter((r) => r.got === 'error').length,
|
|
273
|
+
by_kind: byKind,
|
|
274
|
+
results,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function scoreAgents(items, agentsMod, rules = null) {
|
|
279
|
+
const home = tmpDir('atris-sentinel-bench-home-');
|
|
280
|
+
const results = [];
|
|
281
|
+
try {
|
|
282
|
+
for (const raw of items) {
|
|
283
|
+
const item = expand(raw);
|
|
284
|
+
const dir = tmpDir('atris-sentinel-bench-agents-');
|
|
285
|
+
let findings = [];
|
|
286
|
+
let error = '';
|
|
287
|
+
try {
|
|
288
|
+
writeFiles(dir, item.files);
|
|
289
|
+
findings = agentsMod.runAgentCheck(dir, { home: false, homeDir: home, ...(rules ? { rules } : {}) }).findings || [];
|
|
290
|
+
} catch (e) { error = String(e.message || e).slice(0, 80); }
|
|
291
|
+
finally { rmDir(dir); }
|
|
292
|
+
const expected = [...new Set(item.expect)];
|
|
293
|
+
const gotRules = new Set(findings.map((f) => f.rule));
|
|
294
|
+
const found = expected.filter((r) => gotRules.has(r));
|
|
295
|
+
const falseAlarms = findings.filter((f) => !expected.includes(f.rule));
|
|
296
|
+
results.push({
|
|
297
|
+
id: raw.id,
|
|
298
|
+
kind: expected.length ? 'planted' : 'clean',
|
|
299
|
+
expected,
|
|
300
|
+
found: found.length,
|
|
301
|
+
missed: expected.filter((r) => !gotRules.has(r)),
|
|
302
|
+
false_alarms: falseAlarms.map((f) => f.rule),
|
|
303
|
+
error,
|
|
304
|
+
ok: !error && found.length === expected.length && falseAlarms.length === 0,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
} finally { rmDir(home); }
|
|
308
|
+
const planted = results.filter((r) => r.kind === 'planted');
|
|
309
|
+
const clean = results.filter((r) => r.kind === 'clean');
|
|
310
|
+
return {
|
|
311
|
+
items: results.length,
|
|
312
|
+
planted_items: planted.length,
|
|
313
|
+
expected: planted.reduce((n, r) => n + r.expected.length, 0),
|
|
314
|
+
found: planted.reduce((n, r) => n + r.found, 0),
|
|
315
|
+
clean_items: clean.length,
|
|
316
|
+
false_alarms: results.reduce((n, r) => n + r.false_alarms.length, 0),
|
|
317
|
+
clean_items_flagged: clean.filter((r) => r.false_alarms.length > 0).length,
|
|
318
|
+
errors: results.filter((r) => r.error).length,
|
|
319
|
+
results,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// A confirmed record lands on a file when its fingerprint or trace names it.
|
|
324
|
+
function recordFiles(record) {
|
|
325
|
+
const files = new Set();
|
|
326
|
+
const fp = String(record.fingerprint || '');
|
|
327
|
+
if (fp.includes(':')) files.add(fp.split(':')[0]);
|
|
328
|
+
for (const t of Array.isArray(record.trace) ? record.trace : []) {
|
|
329
|
+
const f = String((t && (t.file || t.path)) || '').replace(/:\d+(?:-\d+)?$/, '');
|
|
330
|
+
if (f) files.add(f);
|
|
331
|
+
}
|
|
332
|
+
return files;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function scoreReview(items, auditMod, { hunter, verifier, profile = 'quick', budget, runJobs, log = () => {} } = {}) {
|
|
336
|
+
const { execFileSync } = require('child_process');
|
|
337
|
+
const results = [];
|
|
338
|
+
let engineCalls = 0;
|
|
339
|
+
let credits = 0;
|
|
340
|
+
for (const raw of items) {
|
|
341
|
+
const item = expand(raw);
|
|
342
|
+
const dir = tmpDir('atris-sentinel-bench-review-');
|
|
343
|
+
const out = tmpDir('atris-sentinel-bench-review-out-');
|
|
344
|
+
let records = [];
|
|
345
|
+
let error = '';
|
|
346
|
+
try {
|
|
347
|
+
writeFiles(dir, item.files);
|
|
348
|
+
const git = (args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' });
|
|
349
|
+
git(['init', '-q']);
|
|
350
|
+
git(['add', '-A']);
|
|
351
|
+
git(['-c', 'user.name=bench', '-c', 'user.email=bench@localhost', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'fixture']);
|
|
352
|
+
const res = await auditMod.runAudit({ root: dir, out, profile, hunter, verifier, ...(Number.isInteger(budget) ? { budget } : {}), ...(runJobs ? { runJobs } : {}), log });
|
|
353
|
+
records = (res.records || []).filter((r) => r.verdict === 'confirmed');
|
|
354
|
+
engineCalls += (res.meta && res.meta.invocations && res.meta.invocations.used) || 0;
|
|
355
|
+
credits += Number((res.meta && res.meta.credits_used) || 0);
|
|
356
|
+
} catch (e) { error = String(e.message || e).slice(0, 120); }
|
|
357
|
+
finally { rmDir(dir); rmDir(out); }
|
|
358
|
+
const holes = item.holes.map((h) => String(h.file));
|
|
359
|
+
const hit = new Set();
|
|
360
|
+
let falseFindings = 0;
|
|
361
|
+
for (const r of records) {
|
|
362
|
+
const files = recordFiles(r);
|
|
363
|
+
const match = holes.filter((h) => files.has(h));
|
|
364
|
+
if (match.length) match.forEach((h) => hit.add(h));
|
|
365
|
+
else falseFindings += 1;
|
|
366
|
+
}
|
|
367
|
+
results.push({ id: raw.id, holes: holes.length, found: hit.size, false_findings: falseFindings, error, ok: !error && hit.size === holes.length && falseFindings === 0 });
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
items: results.length,
|
|
371
|
+
repos: results.length,
|
|
372
|
+
holes: results.reduce((n, r) => n + r.holes, 0),
|
|
373
|
+
found: results.reduce((n, r) => n + r.found, 0),
|
|
374
|
+
false_findings: results.reduce((n, r) => n + r.false_findings, 0),
|
|
375
|
+
engine_calls: engineCalls,
|
|
376
|
+
credits,
|
|
377
|
+
errors: results.filter((r) => r.error).length,
|
|
378
|
+
results,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------- run
|
|
383
|
+
|
|
384
|
+
function loadSet(dir, set, { held = false, env = process.env } = {}) {
|
|
385
|
+
let parsed;
|
|
386
|
+
if (held) { const text = readHeldText(dir, set, env); parsed = text === null ? { items: [], invalid: 0, missing: true } : parseItems(text); }
|
|
387
|
+
else parsed = readItems(path.join(dir, `${set}.jsonl`));
|
|
388
|
+
const { items, invalid, missing } = parsed;
|
|
389
|
+
const good = items.filter((i) => validItem(set, i));
|
|
390
|
+
return { items: good, invalid: invalid + (items.length - good.length), missing };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The full exam. Returns raw numbers per set and half. Held-out results
|
|
394
|
+
// stay in this object; publicReport() drops them before anything prints.
|
|
395
|
+
async function runBench({ target, seenDir, holdoutDir, sets = ['guard', 'agents'], review = null, env = process.env } = {}) {
|
|
396
|
+
const root = path.resolve(target || process.cwd());
|
|
397
|
+
const seen = path.resolve(seenDir || seenDirOf(root));
|
|
398
|
+
const held = path.resolve(holdoutDir || defaultHoldoutDir(env));
|
|
399
|
+
const rules = loadRules(root);
|
|
400
|
+
const guardMod = require('./sentinel-guard');
|
|
401
|
+
const agentsMod = require('./sentinel-agents');
|
|
402
|
+
const want = sets.filter((s) => SETS.includes(s) && (s !== 'review' || review));
|
|
403
|
+
const report = { target: root, sets: want, seen: {}, holdout: {}, holdout_missing: [], holdout_dir: held, sealed_sources: readSealedSources(held) };
|
|
404
|
+
// Seal any plain held-out file, scored this run or not.
|
|
405
|
+
for (const set of SETS) if (fs.existsSync(path.join(held, `${set}.jsonl`))) readHeldText(held, set, env);
|
|
406
|
+
for (const half of ['seen', 'holdout']) {
|
|
407
|
+
const dir = half === 'seen' ? seen : held;
|
|
408
|
+
for (const set of want) {
|
|
409
|
+
const loaded = loadSet(dir, set, { held: half === 'holdout', env });
|
|
410
|
+
if (half === 'holdout' && (loaded.missing || !loaded.items.length)) { report.holdout_missing.push(set); continue; }
|
|
411
|
+
let score;
|
|
412
|
+
if (set === 'guard') score = scoreGuard(loaded.items, guardMod, rules);
|
|
413
|
+
else if (set === 'agents') score = scoreAgents(loaded.items, agentsMod, rules);
|
|
414
|
+
else score = await scoreReview(loaded.items, require('./security-audit'), review || {});
|
|
415
|
+
score.invalid = loaded.invalid;
|
|
416
|
+
report[half][set] = score;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return report;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// What may leave this module: held-out totals only, never an item. With
|
|
423
|
+
// summary, seen items are dropped too.
|
|
424
|
+
function publicReport(report, { summary = false } = {}) {
|
|
425
|
+
const strip = (half, keepItems) => Object.fromEntries(Object.entries(report[half]).map(([set, s]) => {
|
|
426
|
+
const { results, ...totals } = s;
|
|
427
|
+
return [set, keepItems ? { ...totals, results } : totals];
|
|
428
|
+
}));
|
|
429
|
+
return {
|
|
430
|
+
target: report.target,
|
|
431
|
+
sets: report.sets,
|
|
432
|
+
seen: strip('seen', !summary),
|
|
433
|
+
holdout: strip('holdout', false),
|
|
434
|
+
holdout_missing: report.holdout_missing,
|
|
435
|
+
// Where the held-out half lives. The improver uses it to deny an engine
|
|
436
|
+
// reads there. A path, never its contents.
|
|
437
|
+
holdout_dir: report.holdout_dir,
|
|
438
|
+
// Sealed split sources, paths only. The improver uses them to deny reads.
|
|
439
|
+
sealed_sources: report.sealed_sources || [],
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------- split
|
|
444
|
+
|
|
445
|
+
// Splits a new corpus file into the two halves: within each kind, items
|
|
446
|
+
// sorted by a hash of their id alternate seen, held-out. The seen half is
|
|
447
|
+
// appended to the repo corpus, the held-out half to the sealed machine
|
|
448
|
+
// copy. The input file is never deleted. A split that would leave
|
|
449
|
+
// either half with nothing new writes nothing.
|
|
450
|
+
function splitCorpus(file, set, { seenDir, holdoutDir, env = process.env } = {}) {
|
|
451
|
+
if (!SETS.includes(set)) throw new Error(`unknown set ${set}. use guard, agents or review`);
|
|
452
|
+
const crypto = require('crypto');
|
|
453
|
+
const abs = path.resolve(file);
|
|
454
|
+
// Never follow a symbolic link: sealing a link would leave its plaintext
|
|
455
|
+
// target readable. The path and every folder above it must be real.
|
|
456
|
+
for (let p = abs; ; p = path.dirname(p)) {
|
|
457
|
+
let st;
|
|
458
|
+
try { st = fs.lstatSync(p); } catch { if (p === abs) throw new Error(`${file} is not a file`); break; }
|
|
459
|
+
if (st.isSymbolicLink()) {
|
|
460
|
+
let realPath = '';
|
|
461
|
+
try { realPath = fs.realpathSync(abs); } catch {}
|
|
462
|
+
throw new Error(`the source ${file} is or sits under a symbolic link (${p}). nothing was written${realPath ? `. pass the real path: ${realPath}` : ''}`);
|
|
463
|
+
}
|
|
464
|
+
if (path.dirname(p) === p) break;
|
|
465
|
+
}
|
|
466
|
+
if (!fs.lstatSync(abs).isFile()) throw new Error(`${file} is not a file`);
|
|
467
|
+
if (fs.realpathSync(abs) !== abs) throw new Error(`the source ${file} does not resolve to itself. pass the real path: ${fs.realpathSync(abs)}`);
|
|
468
|
+
const real = (x) => { try { return fs.realpathSync(x); } catch { return path.resolve(x); } };
|
|
469
|
+
const repo = repoRootOf(abs);
|
|
470
|
+
const realAbs = real(path.dirname(abs)) + path.sep + path.basename(abs);
|
|
471
|
+
if (repo) { const rr = real(repo); if (realAbs === rr || realAbs.startsWith(`${rr}${path.sep}`)) throw new Error(`the source ${file} is inside the repo. put it outside the checkout so a plaintext exam file is never tracked`); }
|
|
472
|
+
const { items, invalid, missing } = readItems(abs);
|
|
473
|
+
if (missing) throw new Error(`${file} is not a file`);
|
|
474
|
+
const good = items.filter((i) => validItem(set, i));
|
|
475
|
+
const seenFile = path.join(seenDir, `${set}.jsonl`);
|
|
476
|
+
const heldDir = holdoutDir || defaultHoldoutDir(env);
|
|
477
|
+
const heldText = readHeldText(heldDir, set, env) || '';
|
|
478
|
+
const have = new Set([...readItems(seenFile).items, ...parseItems(heldText).items].map((i) => i.id));
|
|
479
|
+
const fresh = good.filter((i) => !have.has(i.id));
|
|
480
|
+
const kindOf = (i) => (set === 'guard' ? (i.kind || i.expect) : set === 'agents' ? (i.expect.length ? 'planted' : 'clean') : 'repo');
|
|
481
|
+
const hash = (i) => crypto.createHash('sha256').update(i.id).digest('hex');
|
|
482
|
+
const groups = new Map();
|
|
483
|
+
for (const i of fresh) { const k = kindOf(i); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(i); }
|
|
484
|
+
const toSeen = [];
|
|
485
|
+
const toHeld = [];
|
|
486
|
+
for (const list of groups.values()) list.sort((a, b) => hash(a).localeCompare(hash(b))).forEach((i, n) => (n % 2 === 0 ? toSeen : toHeld).push(i));
|
|
487
|
+
if (!toSeen.length || !toHeld.length) {
|
|
488
|
+
throw new Error(`nothing written: ${fresh.length} new ${fresh.length === 1 ? 'item' : 'items'} cannot fill both halves (${good.length - fresh.length} already in a half, ${invalid + (items.length - good.length)} not valid)`);
|
|
489
|
+
}
|
|
490
|
+
const lines = (list) => list.map((i) => JSON.stringify(i)).join('\n') + '\n';
|
|
491
|
+
fs.mkdirSync(path.dirname(seenFile), { recursive: true });
|
|
492
|
+
fs.appendFileSync(seenFile, lines(toSeen), { mode: 0o644 });
|
|
493
|
+
writeHeldText(heldDir, set, heldText + lines(toHeld), env);
|
|
494
|
+
// Replace the plaintext source with an encrypted copy under the same key,
|
|
495
|
+
// then register it for the deny list. No plaintext held-out
|
|
496
|
+
// item is left readable anywhere.
|
|
497
|
+
const sealedSource = `${abs}.sealed`;
|
|
498
|
+
fs.writeFileSync(sealedSource, seal(fs.readFileSync(abs, 'utf8'), holdoutKey(env)), { mode: 0o600 });
|
|
499
|
+
fs.rmSync(abs, { force: true });
|
|
500
|
+
registerSealedSource(sealedSource, heldDir);
|
|
501
|
+
return { seen: toSeen.length, holdout: toHeld.length, skipped: good.length - fresh.length, invalid: invalid + (items.length - good.length), seen_file: seenFile, holdout_dir: heldDir, sealed_source: sealedSource };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------- render
|
|
505
|
+
|
|
506
|
+
function plural(n, word, many) {
|
|
507
|
+
return `${n} ${n === 1 ? word : (many || `${word}s`)}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function setLine(set, s) {
|
|
511
|
+
if (set === 'guard') return `guard: ${s.blocked} of ${plural(s.attacks, 'attack')} blocked, ${s.false_blocks} of ${plural(s.benign, 'everyday command')} wrongly blocked${s.errors ? `, ${plural(s.errors, 'error')}` : ''}`;
|
|
512
|
+
if (set === 'agents') return `agent check: ${s.found} of ${plural(s.expected, 'planted problem')} found, ${plural(s.false_alarms, 'false alarm')} (${s.clean_items_flagged} of ${plural(s.clean_items, 'clean setup')} flagged)${s.errors ? `, ${plural(s.errors, 'error')}` : ''}`;
|
|
513
|
+
return `review: ${s.found} of ${plural(s.holes, 'planted hole')} found in ${plural(s.repos, 'repo')}, ${plural(s.false_findings, 'false finding')}, ${plural(s.engine_calls, 'engine call')}, ${s.credits} credits`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function renderReport(pub, { holdoutDir } = {}) {
|
|
517
|
+
const lines = ['', ' sentinel bench', ''];
|
|
518
|
+
lines.push(' seen half (in the repo)');
|
|
519
|
+
for (const set of pub.sets) if (pub.seen[set]) lines.push(` ${setLine(set, pub.seen[set])}`);
|
|
520
|
+
lines.push('');
|
|
521
|
+
lines.push(' held-out half (on this machine, items never shown)');
|
|
522
|
+
for (const set of pub.sets) if (pub.holdout[set]) lines.push(` ${setLine(set, pub.holdout[set])}`);
|
|
523
|
+
if (pub.holdout_missing.length) lines.push(` no held-out ${pub.holdout_missing.join(' or ')} items${holdoutDir ? ` in ${holdoutDir}` : ''}`);
|
|
524
|
+
const misses = [];
|
|
525
|
+
for (const set of pub.sets) {
|
|
526
|
+
const s = pub.seen[set];
|
|
527
|
+
if (!s || !s.results) continue;
|
|
528
|
+
for (const r of s.results) {
|
|
529
|
+
if (r.ok) continue;
|
|
530
|
+
if (set === 'guard') misses.push(` ${r.id} ${r.expect === 'block' ? 'attack ran' : `everyday command blocked by ${r.rule}`}`);
|
|
531
|
+
else if (set === 'agents') misses.push(` ${r.id} ${r.missed.length ? `missed ${r.missed.join(', ')}` : ''}${r.missed.length && r.false_alarms.length ? '. ' : ''}${r.false_alarms.length ? `false alarm ${r.false_alarms.join(', ')}` : ''}${r.error ? `error ${r.error}` : ''}`);
|
|
532
|
+
else misses.push(` ${r.id} ${r.found} of ${r.holes} holes found, ${r.false_findings} false`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (misses.length) { lines.push(''); lines.push(' seen items it got wrong'); lines.push(...misses); }
|
|
536
|
+
lines.push('');
|
|
537
|
+
return lines.join('\n');
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
module.exports = {
|
|
541
|
+
SETS,
|
|
542
|
+
readHeldText,
|
|
543
|
+
readSealedSources,
|
|
544
|
+
FAKE,
|
|
545
|
+
defaultHoldoutDir,
|
|
546
|
+
expand,
|
|
547
|
+
publicReport,
|
|
548
|
+
readItems,
|
|
549
|
+
renderReport,
|
|
550
|
+
runBench,
|
|
551
|
+
scoreAgents,
|
|
552
|
+
scoreGuard,
|
|
553
|
+
scoreReview,
|
|
554
|
+
seenDirOf,
|
|
555
|
+
splitCorpus,
|
|
556
|
+
};
|
package/lib/sentinel-cloud.js
CHANGED
|
@@ -317,7 +317,7 @@ async function quoteEstimate(estimate, { token, base = apiBase(), timeoutMs = 15
|
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
function describeQuote(quote) {
|
|
320
|
-
const parts = quote.rows.map((r) => `${r.calls} ${r.role === 'hunter' ? 'search' : 'check'} call${r.calls === 1 ? '' : 's'} on ${r.model} at most ${r.credits_per_call} credit${r.credits_per_call === 1 ? '' : 's'} each`);
|
|
320
|
+
const parts = quote.rows.map((r) => `${r.calls} ${r.role === 'hunter' ? 'search' : (r.role === 'builder' ? 'fix' : 'check')} call${r.calls === 1 ? '' : 's'} on ${r.model} at most ${r.credits_per_call} credit${r.credits_per_call === 1 ? '' : 's'} each`);
|
|
321
321
|
return [
|
|
322
322
|
`this review runs on atris models and costs at most ${quote.credits} credits`,
|
|
323
323
|
` ${parts.join('; ')}`,
|
|
@@ -557,6 +557,7 @@ module.exports = {
|
|
|
557
557
|
authToken,
|
|
558
558
|
apiBase,
|
|
559
559
|
resolveEngines,
|
|
560
|
+
localEngineAvailable,
|
|
560
561
|
atrisJobRunner,
|
|
561
562
|
quoteEstimate,
|
|
562
563
|
describeQuote,
|