atris 3.79.0 → 3.80.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 +6 -0
- package/atris/atris.md +66 -167
- package/atris/skills/security-audit/SKILL.md +77 -0
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris.md +66 -167
- package/bin/atris.js +6 -0
- package/commands/business.js +44 -1984
- package/commands/member.js +189 -966
- package/commands/mission.js +229 -1409
- package/commands/pack.js +112 -1476
- package/commands/security-review.js +125 -0
- package/commands/task.js +137 -1359
- package/lib/business-core.js +2022 -0
- package/lib/engine-ask.js +1 -0
- package/lib/handoff-wake.js +8 -6
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +21 -2
- package/lib/member-board.js +12 -10
- package/lib/member-core.js +1014 -0
- package/lib/member-up.js +46 -3
- package/lib/mission-core.js +1559 -0
- package/lib/pack-core.js +1588 -0
- package/lib/security-audit-classes.js +300 -0
- package/lib/security-audit.js +1519 -0
- package/lib/task-core.js +1412 -0
- package/lib/task-decision.js +66 -0
- package/lib/task-explanation.js +20 -0
- package/lib/yes-lock.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1519 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris security audit: the sentinel's coverage-led, cross-model audit.
|
|
4
|
+
//
|
|
5
|
+
// The deterministic scan (lib/security-scan.js) answers "did a secret get
|
|
6
|
+
// committed". This answers "can someone with less trust make this code do
|
|
7
|
+
// something it should not", with the method from
|
|
8
|
+
// cloudflare/security-audit-skill (MIT), rebuilt on Atris engines:
|
|
9
|
+
//
|
|
10
|
+
// 1. map free, deterministic: every source file x attack class that has
|
|
11
|
+
// a reason to be looked at becomes a coverage unit in a ledger.
|
|
12
|
+
// 2. hunt read-only engine hunters each own a few units and return one
|
|
13
|
+
// JSON result per unit: covered, candidate, or blocked.
|
|
14
|
+
// 3. critic a fresh engine reads the ledger and names missed areas.
|
|
15
|
+
// 4. verify every candidate goes to a different model that tries to
|
|
16
|
+
// disprove it. Cited file:line must resolve before any spend.
|
|
17
|
+
// 5. recheck confirmed records get one more fresh read of the final words.
|
|
18
|
+
// 6. report REPORT.md, FINDINGS-DETAIL.md, NEEDS-VALIDATION.md, built only
|
|
19
|
+
// from records that passed both validators.
|
|
20
|
+
//
|
|
21
|
+
// Runs are additive. Each run reads the last ledger, skips units whose source
|
|
22
|
+
// has not changed, re-checks old findings, and spends its budget on what is
|
|
23
|
+
// left, so the nightly loop covers the repo slice by slice.
|
|
24
|
+
//
|
|
25
|
+
// Nothing is written inside the target. Output defaults to
|
|
26
|
+
// ~/.atris/security-audit/<repo>/run-<N>. Engines run read-only and never
|
|
27
|
+
// execute target code, so every confirmed record says it was source-traced.
|
|
28
|
+
//
|
|
29
|
+
// Zero external deps (Node built-ins only).
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const crypto = require('crypto');
|
|
35
|
+
const { execFileSync } = require('child_process');
|
|
36
|
+
const {
|
|
37
|
+
CLASSES, CLASS_BY_ID, HUNT_METHOD, CANDIDATE_GATE, SEVERITY_ANCHORS,
|
|
38
|
+
} = require('./security-audit-classes');
|
|
39
|
+
|
|
40
|
+
const PROFILES = ['quick', 'standard', 'deep'];
|
|
41
|
+
const DEFAULT_BUDGET = { quick: 8, standard: 20, deep: 48 };
|
|
42
|
+
const MAX_WAVES = { quick: 1, standard: 2, deep: 3 };
|
|
43
|
+
const UNITS_PER_HUNTER = { quick: 1, standard: 4, deep: 2 };
|
|
44
|
+
const MAX_UNITS = 10000;
|
|
45
|
+
const MAX_HOT_LINES = 6;
|
|
46
|
+
const MAX_QUICK_PATHS = 8;
|
|
47
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
48
|
+
const AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
49
|
+
const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
|
|
50
|
+
const FINGERPRINT_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/;
|
|
51
|
+
const SCORES = ['informational', 'low', 'medium', 'high', 'critical'];
|
|
52
|
+
const SCORE_RANK = Object.fromEntries(SCORES.map((s, i) => [s, i]));
|
|
53
|
+
const STATUSES = ['planned', 'not_applicable', 'out_of_scope', 'deferred', 'in_progress', 'blocked', 'covered', 'candidate'];
|
|
54
|
+
const CONDITION_KINDS = ['authentication_level', 'authorization_role', 'user_interaction', 'system_configuration', 'network_routing', 'environmental_dependency', 'data_state', 'timing_dependency', 'third_party_dependency'];
|
|
55
|
+
const SKIP_PARTS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'out', 'vendor', '.cache', '__pycache__', '_archive', '.agent-worktrees']);
|
|
56
|
+
const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|spec|specs|fixtures?|testdata|__mocks__|e2e)(?:\/|$)|\.(?:test|spec)\.[a-z]+$|_test\.(?:go|py)$/i;
|
|
57
|
+
const SEVERE = new Set(['critical', 'high']);
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------- identity
|
|
60
|
+
|
|
61
|
+
// RFC 3986 percent encoding of the UTF-8 bytes: lossless, so two different
|
|
62
|
+
// references can never collapse into one coverage id.
|
|
63
|
+
function encodeRef(value) {
|
|
64
|
+
const text = String(value);
|
|
65
|
+
if (!text || text !== text.trim() || text.normalize('NFC') !== text || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(text)) {
|
|
66
|
+
throw new Error(`bad canonical reference: ${JSON.stringify(text)}`);
|
|
67
|
+
}
|
|
68
|
+
let out = '';
|
|
69
|
+
for (const byte of Buffer.from(text, 'utf8')) {
|
|
70
|
+
const ch = String.fromCharCode(byte);
|
|
71
|
+
out += /[A-Za-z0-9\-._~]/.test(ch) ? ch : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function coverageId(refs) {
|
|
77
|
+
const parts = [refs.surface, refs.boundary, refs.subsystem, refs.attack_class].map(encodeRef);
|
|
78
|
+
if (refs.lifecycle) parts.push(encodeRef(refs.lifecycle));
|
|
79
|
+
return parts.join('::');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validAgentId(id) {
|
|
83
|
+
return typeof id === 'string' && AGENT_ID_RE.test(id) && !WINDOWS_DEVICE.test(id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sha256(text) {
|
|
87
|
+
return crypto.createHash('sha256').update(text).digest('hex');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------- files
|
|
91
|
+
|
|
92
|
+
function relPosix(p) {
|
|
93
|
+
return p.split(path.sep).join('/');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A path an engine wrote, made repo-relative. Absolute paths inside the root
|
|
97
|
+
// are accepted (models cite them); anything that escapes is refused.
|
|
98
|
+
function normalizeRepoPath(root, raw) {
|
|
99
|
+
if (typeof raw !== 'string' || !raw.trim()) return null;
|
|
100
|
+
let value = raw.trim().replace(/^\.\/+/, '').replace(/:\d+(?::\d+)?$/, '');
|
|
101
|
+
if (path.isAbsolute(value)) {
|
|
102
|
+
const rel = path.relative(root, value);
|
|
103
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
104
|
+
value = rel;
|
|
105
|
+
}
|
|
106
|
+
value = relPosix(path.normalize(value));
|
|
107
|
+
if (!value || value === '.' || value.startsWith('../') || value === '..' || path.isAbsolute(value)) return null;
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listFiles(root) {
|
|
112
|
+
try {
|
|
113
|
+
const out = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
114
|
+
return out.split('\0').filter(Boolean);
|
|
115
|
+
} catch {
|
|
116
|
+
const files = [];
|
|
117
|
+
const walk = (dir) => {
|
|
118
|
+
let entries = [];
|
|
119
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (SKIP_PARTS.has(entry.name)) continue;
|
|
122
|
+
const full = path.join(dir, entry.name);
|
|
123
|
+
if (entry.isDirectory()) walk(full);
|
|
124
|
+
else if (entry.isFile()) files.push(relPosix(path.relative(root, full)));
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
walk(root);
|
|
128
|
+
return files.sort();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function skippedPath(rel) {
|
|
133
|
+
return rel.split('/').some((part) => SKIP_PARTS.has(part));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function inScope(rel, scope) {
|
|
137
|
+
if (!scope.length) return true;
|
|
138
|
+
return scope.some((s) => rel === s || rel.startsWith(s.endsWith('/') ? s : `${s}/`));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Inside the real root, after every symlink is resolved.
|
|
142
|
+
function containedIn(rootReal, real) {
|
|
143
|
+
const rel = path.relative(rootReal, real);
|
|
144
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const realRoots = new Map();
|
|
148
|
+
function realRoot(root) {
|
|
149
|
+
if (!realRoots.has(root)) {
|
|
150
|
+
let real = root;
|
|
151
|
+
try { real = fs.realpathSync(root); } catch {}
|
|
152
|
+
realRoots.set(root, real);
|
|
153
|
+
}
|
|
154
|
+
return realRoots.get(root);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readText(root, rel) {
|
|
158
|
+
try {
|
|
159
|
+
const full = path.join(root, rel);
|
|
160
|
+
const stat = fs.lstatSync(full);
|
|
161
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
|
|
162
|
+
if (!containedIn(realRoot(root), fs.realpathSync(full))) return null;
|
|
163
|
+
const text = fs.readFileSync(full, 'utf8');
|
|
164
|
+
return text.includes('\u0000') ? null : text;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function subsystemOf(rel) {
|
|
171
|
+
const parts = rel.split('/');
|
|
172
|
+
if (parts.length === 1) return '.';
|
|
173
|
+
if (parts.length > 2 && ['packages', 'apps', 'services', 'src'].includes(parts[0])) return `${parts[0]}/${parts[1]}`;
|
|
174
|
+
return parts[0];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sourceDigest(root, paths) {
|
|
178
|
+
const h = crypto.createHash('sha256');
|
|
179
|
+
for (const rel of [...paths].sort()) {
|
|
180
|
+
const text = readText(root, rel);
|
|
181
|
+
h.update(`${rel}\0${text === null ? 'missing' : sha256(text)}\0`);
|
|
182
|
+
}
|
|
183
|
+
return h.digest('hex');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function gitSourceRef(root) {
|
|
187
|
+
try {
|
|
188
|
+
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
189
|
+
const dirty = execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().length > 0;
|
|
190
|
+
return { commit, dirty };
|
|
191
|
+
} catch {
|
|
192
|
+
return { commit: null, dirty: null };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function repoName(root) {
|
|
197
|
+
try {
|
|
198
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
199
|
+
const m = url.match(/([^/:]+?)(?:\.git)?$/);
|
|
200
|
+
if (m && m[1]) return m[1].replace(/[^A-Za-z0-9._-]/g, '-');
|
|
201
|
+
} catch {}
|
|
202
|
+
return path.basename(path.resolve(root)).replace(/[^A-Za-z0-9._-]/g, '-') || 'repo';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function writeJson(file, value) {
|
|
206
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
207
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
208
|
+
fs.renameSync(tmp, file);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function readJson(file) {
|
|
212
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------- map (phase 1)
|
|
216
|
+
|
|
217
|
+
function classesForFile(rel) {
|
|
218
|
+
const ext = path.extname(rel).toLowerCase();
|
|
219
|
+
return CLASSES.filter((c) => (c.files ? c.files(rel) : c.exts && c.exts.has(ext)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function detectHits(text, cls) {
|
|
223
|
+
const hits = [];
|
|
224
|
+
const lines = text.split('\n');
|
|
225
|
+
for (let i = 0; i < lines.length && hits.length < 200; i++) {
|
|
226
|
+
const line = lines[i];
|
|
227
|
+
if (line.length > 2000) continue;
|
|
228
|
+
if (cls.detect.some((re) => re.test(line))) hits.push({ line: i + 1, text: line.trim().slice(0, 160) });
|
|
229
|
+
}
|
|
230
|
+
return hits;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function classPriority(cls, rel, hits) {
|
|
234
|
+
let p = cls.weight * 10 + Math.min(hits, 10);
|
|
235
|
+
if (/^(?:bin|commands|lib|src|server|api|app|routes|utils)\//.test(rel) || !rel.includes('/')) p += 5;
|
|
236
|
+
if (/^(?:scripts|examples|docs|benchmarks?)\//.test(rel)) p -= 20;
|
|
237
|
+
return p;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function newUnit({ refs, surface, boundary, subsystem, attackClass, paths, hot, priority, digest }) {
|
|
241
|
+
return {
|
|
242
|
+
coverage_id: coverageId(refs),
|
|
243
|
+
canonical_refs: refs,
|
|
244
|
+
surface,
|
|
245
|
+
boundary,
|
|
246
|
+
subsystem,
|
|
247
|
+
attack_class: attackClass,
|
|
248
|
+
starting_paths: paths,
|
|
249
|
+
hot_lines: hot,
|
|
250
|
+
source_digest: digest,
|
|
251
|
+
priority,
|
|
252
|
+
prior_status: 'none',
|
|
253
|
+
attempts: [],
|
|
254
|
+
wave: 1,
|
|
255
|
+
status: 'planned',
|
|
256
|
+
agent_id: null,
|
|
257
|
+
reviewed_paths: [],
|
|
258
|
+
local_checks: [],
|
|
259
|
+
result_fingerprints: [],
|
|
260
|
+
unresolved: [],
|
|
261
|
+
hardening: [],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Deterministic recon. The same source gives the same ledger every time, and
|
|
266
|
+
// costs nothing, so the whole budget goes to hunters and verifiers.
|
|
267
|
+
function mapTarget(root, { profile = 'standard', scope = [] } = {}) {
|
|
268
|
+
const all = listFiles(root).filter((rel) => !skippedPath(rel));
|
|
269
|
+
const perFileClass = [];
|
|
270
|
+
const stats = { files: all.length, source_files: 0, test_files_skipped: 0, out_of_scope_files: 0, languages: {} };
|
|
271
|
+
const outOfScope = new Map();
|
|
272
|
+
for (const rel of all) {
|
|
273
|
+
const classes = classesForFile(rel);
|
|
274
|
+
if (!classes.length) continue;
|
|
275
|
+
if (TEST_PATH.test(rel)) { stats.test_files_skipped += 1; continue; }
|
|
276
|
+
const text = readText(root, rel);
|
|
277
|
+
if (text === null) continue;
|
|
278
|
+
stats.source_files += 1;
|
|
279
|
+
const ext = path.extname(rel).toLowerCase() || path.basename(rel);
|
|
280
|
+
stats.languages[ext] = (stats.languages[ext] || 0) + 1;
|
|
281
|
+
for (const cls of classes) {
|
|
282
|
+
const hits = detectHits(text, cls);
|
|
283
|
+
if (!hits.length) continue;
|
|
284
|
+
if (!inScope(rel, scope)) {
|
|
285
|
+
stats.out_of_scope_files += 1;
|
|
286
|
+
const key = `${subsystemOf(rel)}\0${cls.id}`;
|
|
287
|
+
if (!outOfScope.has(key)) outOfScope.set(key, { subsystem: subsystemOf(rel), cls, paths: [] });
|
|
288
|
+
outOfScope.get(key).paths.push(rel);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
perFileClass.push({ rel, cls, hits });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const units = [];
|
|
296
|
+
if (profile === 'quick') {
|
|
297
|
+
const groups = new Map();
|
|
298
|
+
for (const item of perFileClass) {
|
|
299
|
+
const key = `${subsystemOf(item.rel)}\0${item.cls.id}`;
|
|
300
|
+
if (!groups.has(key)) groups.set(key, { subsystem: subsystemOf(item.rel), cls: item.cls, items: [] });
|
|
301
|
+
groups.get(key).items.push(item);
|
|
302
|
+
}
|
|
303
|
+
for (const g of groups.values()) {
|
|
304
|
+
g.items.sort((a, b) => b.hits.length - a.hits.length || a.rel.localeCompare(b.rel));
|
|
305
|
+
const top = g.items.slice(0, MAX_QUICK_PATHS);
|
|
306
|
+
const paths = top.map((i) => i.rel);
|
|
307
|
+
const surface = `${g.subsystem}/*`;
|
|
308
|
+
const refs = { surface, boundary: `classes#${g.cls.id}/boundary`, subsystem: 'profile/quick/all-in-scope-subsystems', attack_class: `classes#${g.cls.id}` };
|
|
309
|
+
const hot = top.flatMap((i) => i.hits.slice(0, 2).map((h) => ({ file: i.rel, ...h }))).slice(0, MAX_HOT_LINES);
|
|
310
|
+
const totalHits = g.items.reduce((n, i) => n + i.hits.length, 0);
|
|
311
|
+
const unit = newUnit({ refs, surface, boundary: g.cls.boundary, subsystem: g.subsystem, attackClass: g.cls.id, paths, hot, priority: classPriority(g.cls, paths[0], totalHits) + Math.min(g.items.length, 10), digest: sourceDigest(root, paths) });
|
|
312
|
+
unit.files_in_group = g.items.length;
|
|
313
|
+
units.push(unit);
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
for (const { rel, cls, hits } of perFileClass) {
|
|
317
|
+
const refs = { surface: rel, boundary: `classes#${cls.id}/boundary`, subsystem: subsystemOf(rel), attack_class: `classes#${cls.id}` };
|
|
318
|
+
units.push(newUnit({
|
|
319
|
+
refs, surface: rel, boundary: cls.boundary, subsystem: subsystemOf(rel), attackClass: cls.id,
|
|
320
|
+
paths: [rel], hot: hits.slice(0, MAX_HOT_LINES).map((h) => ({ file: rel, ...h })),
|
|
321
|
+
priority: classPriority(cls, rel, hits.length), digest: sourceDigest(root, [rel]),
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (const g of outOfScope.values()) {
|
|
326
|
+
const surface = `${g.subsystem}/*`;
|
|
327
|
+
const refs = { surface, boundary: `classes#${g.cls.id}/boundary`, subsystem: g.subsystem, attack_class: `classes#${g.cls.id}`, lifecycle: 'scope/out-of-scope' };
|
|
328
|
+
const unit = newUnit({ refs, surface, boundary: g.cls.boundary, subsystem: g.subsystem, attackClass: g.cls.id, paths: g.paths.slice(0, MAX_QUICK_PATHS), hot: [], priority: 0, digest: '' });
|
|
329
|
+
unit.status = 'out_of_scope';
|
|
330
|
+
unit.unresolved = ['outside the paths this run was asked to audit'];
|
|
331
|
+
units.push(unit);
|
|
332
|
+
}
|
|
333
|
+
units.sort((a, b) => (a.coverage_id < b.coverage_id ? -1 : a.coverage_id > b.coverage_id ? 1 : 0));
|
|
334
|
+
const truncated = units.length > MAX_UNITS;
|
|
335
|
+
return { units: truncated ? topByPriority(units, MAX_UNITS) : units, stats, truncated };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function topByPriority(units, n) {
|
|
339
|
+
return [...units].sort((a, b) => b.priority - a.priority || cmpId(a, b)).slice(0, n).sort(cmpId);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function cmpId(a, b) {
|
|
343
|
+
return a.coverage_id < b.coverage_id ? -1 : a.coverage_id > b.coverage_id ? 1 : 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function architectureText({ repo, root, stats, units, scope, profile, prior, scanSeeds }) {
|
|
347
|
+
const byClass = {};
|
|
348
|
+
const bySub = {};
|
|
349
|
+
for (const u of units) {
|
|
350
|
+
if (u.status === 'out_of_scope') continue;
|
|
351
|
+
byClass[u.attack_class] = (byClass[u.attack_class] || 0) + 1;
|
|
352
|
+
bySub[u.subsystem] = (bySub[u.subsystem] || 0) + 1;
|
|
353
|
+
}
|
|
354
|
+
const langs = Object.entries(stats.languages).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
355
|
+
const subs = Object.entries(bySub).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([k, v]) => `${k} (${v})`).join(', ');
|
|
356
|
+
const classes = Object.entries(byClass).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
357
|
+
const lines = [
|
|
358
|
+
`# Architecture: ${repo}`,
|
|
359
|
+
'',
|
|
360
|
+
`Target: ${root}`,
|
|
361
|
+
`Profile: ${profile}${scope.length ? `, scoped to ${scope.join(', ')}` : ''}`,
|
|
362
|
+
`Tracked files: ${stats.files}. Source files read: ${stats.source_files}. Test files left out: ${stats.test_files_skipped}.`,
|
|
363
|
+
`Languages: ${langs || 'none detected'}.`,
|
|
364
|
+
`Subsystems with review surface: ${subs || 'none'}.`,
|
|
365
|
+
`Coverage units by attack class: ${classes || 'none'}.`,
|
|
366
|
+
prior ? `Prior run: ${prior.dir} (${prior.units} units, ${prior.findings} records).` : 'Prior run: none. This is the first ledger for this repo, so coverage starts from zero.',
|
|
367
|
+
];
|
|
368
|
+
if (scanSeeds.length) {
|
|
369
|
+
lines.push('', 'Deterministic scan evidence (lib/security-scan.js) at high or critical:');
|
|
370
|
+
for (const f of scanSeeds.slice(0, 12)) lines.push(`- ${f.file}${f.line ? `:${f.line}` : ''} ${f.rule}: ${f.why}`);
|
|
371
|
+
}
|
|
372
|
+
lines.push('', 'Detection lines only seed coverage. A hit is a place to look, never a finding.');
|
|
373
|
+
return `${lines.join('\n')}\n`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------- prior runs
|
|
377
|
+
|
|
378
|
+
function auditHome() {
|
|
379
|
+
return process.env.ATRIS_SECURITY_AUDIT_HOME || path.join(os.homedir(), '.atris', 'security-audit');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function runDirs(outRoot) {
|
|
383
|
+
let names = [];
|
|
384
|
+
try { names = fs.readdirSync(outRoot); } catch { return []; }
|
|
385
|
+
return names
|
|
386
|
+
.map((n) => ({ n, m: n.match(/^run-(\d+)$/) }))
|
|
387
|
+
.filter((x) => x.m)
|
|
388
|
+
.map((x) => ({ dir: path.join(outRoot, x.n), num: Number(x.m[1]) }))
|
|
389
|
+
.sort((a, b) => a.num - b.num);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function loadPrior(outRoot, exclude) {
|
|
393
|
+
const runs = runDirs(outRoot).filter((r) => r.dir !== exclude).reverse();
|
|
394
|
+
for (const r of runs) {
|
|
395
|
+
const ledger = readJson(path.join(r.dir, 'coverage-ledger.json'));
|
|
396
|
+
if (!Array.isArray(ledger)) continue;
|
|
397
|
+
const meta = readJson(path.join(r.dir, 'run-metadata.json'));
|
|
398
|
+
if (meta && meta.incomplete_reason === 'plan_only') continue;
|
|
399
|
+
// Records a run could not re-check wait in carried-pending.json so a
|
|
400
|
+
// cheap or cut-short run never makes an old finding disappear.
|
|
401
|
+
const findings = readJson(path.join(r.dir, 'findings.json'));
|
|
402
|
+
const pending = readJson(path.join(r.dir, 'carried-pending.json'));
|
|
403
|
+
const all = [...(Array.isArray(findings) ? findings : []), ...(Array.isArray(pending) ? pending : [])];
|
|
404
|
+
return { dir: r.dir, ledger, findings: all, units: ledger.length, findingsCount: all.length };
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Fold the last ledger in. Unchanged covered units stay covered (with their
|
|
410
|
+
// old evidence, marked carried) so the budget goes somewhere new; changed or
|
|
411
|
+
// unfinished units come back as planned and jump the queue.
|
|
412
|
+
function mergePrior(units, prior) {
|
|
413
|
+
const carried = [];
|
|
414
|
+
const exclusions = [];
|
|
415
|
+
if (!prior) return { carried, exclusions };
|
|
416
|
+
const priorById = new Map(prior.ledger.map((u) => [u.coverage_id, u]));
|
|
417
|
+
const unitById = new Map(units.map((u) => [u.coverage_id, u]));
|
|
418
|
+
for (const u of units) {
|
|
419
|
+
u.prior_status = 'new';
|
|
420
|
+
if (u.status === 'out_of_scope') continue;
|
|
421
|
+
const p = priorById.get(u.coverage_id);
|
|
422
|
+
if (!p) { u.priority += 15; continue; }
|
|
423
|
+
const same = p.source_digest && p.source_digest === u.source_digest;
|
|
424
|
+
if (p.status === 'covered') {
|
|
425
|
+
if (same) {
|
|
426
|
+
u.prior_status = 'prior_covered_same_source';
|
|
427
|
+
u.status = 'covered';
|
|
428
|
+
u.carried_from = prior.dir;
|
|
429
|
+
u.agent_id = p.agent_id;
|
|
430
|
+
u.reviewed_paths = p.reviewed_paths || [];
|
|
431
|
+
u.local_checks = p.local_checks || [];
|
|
432
|
+
u.wave = p.wave || 1;
|
|
433
|
+
} else {
|
|
434
|
+
u.prior_status = 'prior_covered_changed_source';
|
|
435
|
+
u.priority += 30;
|
|
436
|
+
}
|
|
437
|
+
} else if (p.status === 'candidate') {
|
|
438
|
+
u.prior_status = same ? 'prior_confirmed_same_source' : 'prior_confirmed_changed_source';
|
|
439
|
+
u.priority += 30;
|
|
440
|
+
} else if (['deferred', 'blocked', 'planned', 'in_progress'].includes(p.status)) {
|
|
441
|
+
u.prior_status = p.status === 'blocked' ? 'prior_blocked' : 'prior_deferred';
|
|
442
|
+
u.priority += 20;
|
|
443
|
+
} else if (p.status === 'out_of_scope') {
|
|
444
|
+
u.prior_status = 'prior_out_of_scope';
|
|
445
|
+
u.priority += 20;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const rec of prior.findings) {
|
|
449
|
+
if (!rec || !rec.fingerprint) continue;
|
|
450
|
+
const ids = Array.isArray(rec.coverage_ids) ? rec.coverage_ids : [];
|
|
451
|
+
const linked = ids.map((id) => unitById.get(id)).filter(Boolean);
|
|
452
|
+
// Every linked area must still exist, be in scope, and be unchanged.
|
|
453
|
+
const unchanged = linked.length > 0 && linked.length === ids.length && linked.every((u) => {
|
|
454
|
+
if (u.status === 'out_of_scope') return false;
|
|
455
|
+
const p = priorById.get(u.coverage_id);
|
|
456
|
+
return p && p.source_digest === u.source_digest;
|
|
457
|
+
});
|
|
458
|
+
if (rec.verdict === 'rejected') {
|
|
459
|
+
if (unchanged) exclusions.push({ fingerprint: rec.fingerprint, title: rec.title || '', why: 'disproved last run' });
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (unchanged) {
|
|
463
|
+
carried.push({ ...rec, carried_from: prior.dir });
|
|
464
|
+
exclusions.push({ fingerprint: rec.fingerprint, title: rec.title || '', why: 'already on record, being re-checked' });
|
|
465
|
+
// The verifier that re-checks this record owns these units; hunters
|
|
466
|
+
// skip them so the same root cause is not paid for twice.
|
|
467
|
+
for (const u of linked) {
|
|
468
|
+
if (u.status === 'covered') continue;
|
|
469
|
+
resetUnit(u, 'planned');
|
|
470
|
+
u.reserved_for = [...new Set([...(u.reserved_for || []), rec.fingerprint])];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { carried, exclusions };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------- validators
|
|
478
|
+
|
|
479
|
+
function isRepoRel(p) {
|
|
480
|
+
return typeof p === 'string' && p.length > 0 && !path.isAbsolute(p) && !p.split('/').includes('..') && !p.includes('\\');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function validateLedger(units) {
|
|
484
|
+
const errors = [];
|
|
485
|
+
const push = (msg) => { if (errors.length < 100) errors.push(msg); };
|
|
486
|
+
if (!Array.isArray(units)) return ['ledger must be an array'];
|
|
487
|
+
const seen = new Map();
|
|
488
|
+
const tuples = new Map();
|
|
489
|
+
let prev = null;
|
|
490
|
+
units.forEach((u, i) => {
|
|
491
|
+
const at = `unit ${i} (${u && u.coverage_id})`;
|
|
492
|
+
if (!u || typeof u !== 'object') return push(`${at}: not an object`);
|
|
493
|
+
let expected = null;
|
|
494
|
+
try { expected = coverageId(u.canonical_refs || {}); } catch (e) { push(`${at}: ${e.message}`); }
|
|
495
|
+
if (expected && expected !== u.coverage_id) push(`${at}: coverage_id does not match canonical_refs`);
|
|
496
|
+
if (seen.has(u.coverage_id)) push(`${at}: duplicate coverage_id`);
|
|
497
|
+
seen.set(u.coverage_id, true);
|
|
498
|
+
const tuple = [u.surface, u.boundary, u.subsystem, u.attack_class, (u.canonical_refs || {}).lifecycle || ''].join('\0');
|
|
499
|
+
if (tuples.has(tuple) && tuples.get(tuple) !== u.coverage_id) push(`${at}: same unit under two canonical ids`);
|
|
500
|
+
tuples.set(tuple, u.coverage_id);
|
|
501
|
+
if (prev !== null && !(prev < u.coverage_id)) push(`${at}: ledger not sorted by coverage_id`);
|
|
502
|
+
prev = u.coverage_id;
|
|
503
|
+
if (!CLASS_BY_ID.has(u.attack_class)) push(`${at}: unknown attack_class`);
|
|
504
|
+
if (!STATUSES.includes(u.status)) return push(`${at}: unknown status ${u.status}`);
|
|
505
|
+
for (const p of [...(u.starting_paths || []), ...(u.reviewed_paths || [])]) if (!isRepoRel(p)) push(`${at}: path not repo-relative: ${p}`);
|
|
506
|
+
const reviewed = u.reviewed_paths || [];
|
|
507
|
+
const checks = u.local_checks || [];
|
|
508
|
+
const fps = u.result_fingerprints || [];
|
|
509
|
+
const unresolved = u.unresolved || [];
|
|
510
|
+
const empty = !reviewed.length && !checks.length && !fps.length;
|
|
511
|
+
const owner = u.agent_id;
|
|
512
|
+
switch (u.status) {
|
|
513
|
+
case 'planned':
|
|
514
|
+
if (owner !== null || !empty || unresolved.length) push(`${at}: planned must have no owner, evidence, or unresolved`);
|
|
515
|
+
break;
|
|
516
|
+
case 'not_applicable': case 'out_of_scope': case 'deferred':
|
|
517
|
+
if (owner !== null || !empty) push(`${at}: ${u.status} must have no owner or evidence`);
|
|
518
|
+
if (!unresolved.length) push(`${at}: ${u.status} needs a reason in unresolved`);
|
|
519
|
+
break;
|
|
520
|
+
case 'in_progress':
|
|
521
|
+
if (!validAgentId(owner) || !empty || unresolved.length) push(`${at}: in_progress needs an owner and no evidence`);
|
|
522
|
+
break;
|
|
523
|
+
case 'blocked':
|
|
524
|
+
if (!validAgentId(owner) || !reviewed.length || !checks.length || fps.length || !unresolved.length) push(`${at}: blocked needs owner, paths, checks, a blocker, and no fingerprints`);
|
|
525
|
+
break;
|
|
526
|
+
case 'covered':
|
|
527
|
+
if (!validAgentId(owner) || !reviewed.length || !checks.length || fps.length || unresolved.length) push(`${at}: covered needs owner, paths, checks, and nothing unresolved`);
|
|
528
|
+
break;
|
|
529
|
+
case 'candidate':
|
|
530
|
+
if (!validAgentId(owner) || !reviewed.length || !checks.length || !fps.length) push(`${at}: candidate needs owner, paths, checks, and fingerprints`);
|
|
531
|
+
break;
|
|
532
|
+
default: break;
|
|
533
|
+
}
|
|
534
|
+
if (checks.length) {
|
|
535
|
+
const union = new Set();
|
|
536
|
+
for (const c of checks) {
|
|
537
|
+
if (!validAgentId(c.agent_id)) push(`${at}: check has bad agent_id`);
|
|
538
|
+
if (!Array.isArray(c.reviewed_paths) || !c.reviewed_paths.length) push(`${at}: check has no reviewed_paths`);
|
|
539
|
+
for (const p of c.reviewed_paths || []) union.add(p);
|
|
540
|
+
if (c.method !== 'source' && c.method !== 'local') push(`${at}: check method must be source or local`);
|
|
541
|
+
if (c.method === 'source' && c.artifact !== null) push(`${at}: source check must have artifact null`);
|
|
542
|
+
}
|
|
543
|
+
const unitSet = new Set(reviewed);
|
|
544
|
+
if (union.size !== unitSet.size || [...union].some((p) => !unitSet.has(p))) push(`${at}: reviewed_paths must equal the union of check paths`);
|
|
545
|
+
}
|
|
546
|
+
for (const fp of fps) if (!FINGERPRINT_RE.test(fp)) push(`${at}: bad fingerprint ${fp}`);
|
|
547
|
+
});
|
|
548
|
+
return errors;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function nonEmptyString(v) {
|
|
552
|
+
return typeof v === 'string' && v.trim().length > 0;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function validateTrace(trace, at, push, root) {
|
|
556
|
+
if (!Array.isArray(trace) || !trace.length) return push(`${at}: trace must be a nonempty array`);
|
|
557
|
+
trace.forEach((t, i) => {
|
|
558
|
+
if (!t || !['entrypoint', 'propagation', 'sink'].includes(t.kind)) push(`${at}: trace ${i} bad kind`);
|
|
559
|
+
if (!isRepoRel(t && t.file)) push(`${at}: trace ${i} file not repo-relative`);
|
|
560
|
+
if (!Number.isInteger(t && t.line) || t.line < 1) push(`${at}: trace ${i} line must be a positive integer`);
|
|
561
|
+
if (!nonEmptyString(t && t.description)) push(`${at}: trace ${i} needs a description`);
|
|
562
|
+
if (root && t && isRepoRel(t.file)) {
|
|
563
|
+
const text = readText(root, t.file);
|
|
564
|
+
if (text === null) push(`${at}: trace ${i} cites a file that does not exist: ${t.file}`);
|
|
565
|
+
else if (t.line > text.split('\n').length) push(`${at}: trace ${i} cites line ${t.line} past the end of ${t.file}`);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
if (trace.length > 1) {
|
|
569
|
+
if (trace[0].kind !== 'entrypoint') push(`${at}: trace must start at entrypoint`);
|
|
570
|
+
if (trace[trace.length - 1].kind !== 'sink') push(`${at}: trace must end at sink`);
|
|
571
|
+
for (let i = 1; i < trace.length - 1; i++) if (trace[i].kind !== 'propagation') push(`${at}: middle trace steps must be propagation`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// The findings contract. With root set, every cited file:line must resolve
|
|
576
|
+
// in the current source, so a hallucinated citation cannot reach a report.
|
|
577
|
+
function validateFindings(records, { root = null } = {}) {
|
|
578
|
+
const errors = [];
|
|
579
|
+
const push = (msg) => { if (errors.length < 100) errors.push(msg); };
|
|
580
|
+
if (!Array.isArray(records)) return ['findings must be an array'];
|
|
581
|
+
const seen = new Set();
|
|
582
|
+
records.forEach((r, i) => {
|
|
583
|
+
const at = `record ${i} (${r && r.fingerprint})`;
|
|
584
|
+
if (!r || typeof r !== 'object') return push(`${at}: not an object`);
|
|
585
|
+
if (!FINGERPRINT_RE.test(String(r.fingerprint || ''))) push(`${at}: bad fingerprint`);
|
|
586
|
+
if (seen.has(r.fingerprint)) push(`${at}: duplicate fingerprint`);
|
|
587
|
+
seen.add(r.fingerprint);
|
|
588
|
+
if (!nonEmptyString(r.title)) push(`${at}: needs a title`);
|
|
589
|
+
if (!CLASS_BY_ID.has(r.attack_class)) push(`${at}: unknown attack_class`);
|
|
590
|
+
if (!Array.isArray(r.coverage_ids)) push(`${at}: coverage_ids must be an array`);
|
|
591
|
+
if (r.verdict === 'confirmed') {
|
|
592
|
+
for (const k of ['description', 'root_cause', 'intended_behavior', 'attacker', 'impact']) if (!nonEmptyString(r[k])) push(`${at}: confirmed needs ${k}`);
|
|
593
|
+
validateTrace(r.trace, at, push, root);
|
|
594
|
+
const s = r.severity;
|
|
595
|
+
if (!s || !s.likelihood || !s.impact || !SCORES.includes(s.likelihood.score) || !SCORES.includes(s.impact.score) || !SCORES.includes(s.overall)) {
|
|
596
|
+
push(`${at}: confirmed needs severity with likelihood, impact, and overall scores`);
|
|
597
|
+
} else {
|
|
598
|
+
if (!nonEmptyString(s.likelihood.reason) || !nonEmptyString(s.impact.reason)) push(`${at}: severity scores need reasons`);
|
|
599
|
+
if (SCORE_RANK[s.overall] > SCORE_RANK[s.impact.score]) push(`${at}: overall severity cannot exceed impact`);
|
|
600
|
+
}
|
|
601
|
+
if (!r.remediation || !nonEmptyString(r.remediation.strategy)) push(`${at}: confirmed needs a remediation strategy`);
|
|
602
|
+
for (const c of r.conditions || []) if (!CONDITION_KINDS.includes(c && c.kind) || !nonEmptyString(c && c.description)) push(`${at}: bad condition`);
|
|
603
|
+
if (!Array.isArray(r.verified_by) || !r.verified_by.length) push(`${at}: confirmed needs an independent verifier`);
|
|
604
|
+
if (r.found_by && Array.isArray(r.verified_by) && r.verified_by.some((v) => v.agent_id === r.found_by.agent_id)) push(`${at}: the finder cannot verify its own finding`);
|
|
605
|
+
} else if (r.verdict === 'needs_validation') {
|
|
606
|
+
if (r.severity !== undefined) push(`${at}: needs_validation must not carry severity`);
|
|
607
|
+
if (!nonEmptyString(r.claimed_root_cause)) push(`${at}: needs_validation needs claimed_root_cause`);
|
|
608
|
+
validateTrace(r.trace, at, push, root);
|
|
609
|
+
if (!Array.isArray(r.blockers) || !r.blockers.length || !r.blockers.every(nonEmptyString)) push(`${at}: needs_validation needs exact blockers`);
|
|
610
|
+
if (!Array.isArray(r.validation_plan) || !r.validation_plan.length || !r.validation_plan.every((s) => s && ['local', 'deployment'].includes(s.context) && nonEmptyString(s.step))) push(`${at}: needs_validation needs a validation_plan of local or deployment steps`);
|
|
611
|
+
} else if (r.verdict === 'rejected') {
|
|
612
|
+
if (r.severity !== undefined) push(`${at}: rejected must not carry severity`);
|
|
613
|
+
if (!nonEmptyString(r.reason)) push(`${at}: rejected needs a reason`);
|
|
614
|
+
} else {
|
|
615
|
+
push(`${at}: verdict must be confirmed, needs_validation, or rejected`);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
return errors;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------- engines
|
|
622
|
+
|
|
623
|
+
function extractJson(text, accept = () => true) {
|
|
624
|
+
const src = String(text || '');
|
|
625
|
+
const found = [];
|
|
626
|
+
const fence = /```(?:json)?\s*\n([\s\S]*?)```/g;
|
|
627
|
+
let m;
|
|
628
|
+
while ((m = fence.exec(src))) {
|
|
629
|
+
try { const v = JSON.parse(m[1]); if (v && typeof v === 'object' && accept(v)) found.push(v); } catch {}
|
|
630
|
+
}
|
|
631
|
+
if (found.length) return found[found.length - 1];
|
|
632
|
+
const spans = [];
|
|
633
|
+
for (let start = src.indexOf('{'); start !== -1; start = src.indexOf('{', start + 1)) {
|
|
634
|
+
let depth = 0;
|
|
635
|
+
let inStr = false;
|
|
636
|
+
let esc = false;
|
|
637
|
+
for (let i = start; i < src.length; i++) {
|
|
638
|
+
const ch = src[i];
|
|
639
|
+
if (inStr) {
|
|
640
|
+
if (esc) esc = false;
|
|
641
|
+
else if (ch === '\\') esc = true;
|
|
642
|
+
else if (ch === '"') inStr = false;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (ch === '"') inStr = true;
|
|
646
|
+
else if (ch === '{') depth += 1;
|
|
647
|
+
else if (ch === '}') {
|
|
648
|
+
depth -= 1;
|
|
649
|
+
if (depth === 0) {
|
|
650
|
+
try {
|
|
651
|
+
const v = JSON.parse(src.slice(start, i + 1));
|
|
652
|
+
if (v && typeof v === 'object' && accept(v)) spans.push({ v, start, end: i });
|
|
653
|
+
} catch {}
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// The outermost accepted object wins; a nested match is part of it.
|
|
660
|
+
const outer = spans.filter((a) => !spans.some((b) => b !== a && b.start <= a.start && b.end >= a.end));
|
|
661
|
+
return outer.length ? outer[outer.length - 1].v : null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function defaultRunJobs(jobs, { root, concurrency, timeoutMs }) {
|
|
665
|
+
const { runEngineAskJobs, askRosterPin } = require('./engine-ask');
|
|
666
|
+
const pinned = jobs.map((job) => {
|
|
667
|
+
if (job.model) return job;
|
|
668
|
+
const pin = askRosterPin ? askRosterPin(job.engine, root) : {};
|
|
669
|
+
return { ...job, model: pin.roster_model || '', ...(pin.roster_effort ? { effort: pin.roster_effort } : {}) };
|
|
670
|
+
});
|
|
671
|
+
return runEngineAskJobs(pinned, { root, concurrency, timeoutMs });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function bullet(lines) {
|
|
675
|
+
return lines.map((l) => `- ${l}`).join('\n');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function unitBlock(u) {
|
|
679
|
+
const hot = (u.hot_lines || []).map((h) => ` ${h.file}:${h.line} ${h.text}`).join('\n');
|
|
680
|
+
return [
|
|
681
|
+
`- coverage_id: ${u.coverage_id}`,
|
|
682
|
+
` attack class: ${u.attack_class} (${CLASS_BY_ID.get(u.attack_class).title})`,
|
|
683
|
+
` boundary: ${u.boundary}`,
|
|
684
|
+
` subsystem: ${u.subsystem}`,
|
|
685
|
+
` starting paths: ${u.starting_paths.join(', ')}`,
|
|
686
|
+
hot ? ` lines that seeded this unit (places to start, not findings):\n${hot}` : '',
|
|
687
|
+
].filter(Boolean).join('\n');
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function hunterPrompt({ architecture, units, exclusions, peerIds }) {
|
|
691
|
+
const classes = [...new Set(units.map((u) => u.attack_class))].map((id) => CLASS_BY_ID.get(id));
|
|
692
|
+
const contract = {
|
|
693
|
+
units: [{ coverage_id: 'one assigned id', disposition: 'covered|candidate|blocked', reviewed_paths: ['repo/relative/path'], checks: [{ invariant: 'the control you checked', method: 'source', result: 'what the source established', reviewed_paths: ['repo/relative/path'] }], candidate_fingerprints: [], unresolved: [] }],
|
|
694
|
+
candidates: [{
|
|
695
|
+
proposed_verdict: 'confirmed|needs_validation', fingerprint: 'path:scope/root-cause-name', title: '', description: '', attack_class: classes[0].id,
|
|
696
|
+
coverage_ids: [], attacker: 'the lower-trust principal and what they start with', root_cause: '(confirmed) the source defect', claimed_root_cause: '(needs_validation) the suspected defect',
|
|
697
|
+
intended_behavior: '', impact: 'the concrete result across the boundary',
|
|
698
|
+
trace: [{ kind: 'entrypoint|propagation|sink', file: 'repo/relative/path', line: 1, description: '' }],
|
|
699
|
+
conditions: [{ kind: CONDITION_KINDS.join('|'), description: '' }],
|
|
700
|
+
severity: { likelihood: { score: SCORES.join('|'), reason: '' }, impact: { score: SCORES.join('|'), reason: '' }, overall: SCORES.join('|') },
|
|
701
|
+
remediation: { strategy: 'the smallest source change at the last trusted decision point', regression_test: 'the test that would fail today' },
|
|
702
|
+
blockers: ['(needs_validation) the exact missing fact'], validation_plan: [{ context: 'local|deployment', step: '' }],
|
|
703
|
+
}],
|
|
704
|
+
hardening: ['a concrete note that is not a finding'],
|
|
705
|
+
uncovered: [{ surface: 'repo/relative/path', attack_class: CLASSES.map((c) => c.id).join('|'), starting_paths: ['repo/relative/path'], reason: 'a different boundary you saw but do not own' }],
|
|
706
|
+
};
|
|
707
|
+
return [
|
|
708
|
+
'You are a security hunter on the Atris sentinel team. Find source-grounded security invariant failures in your assigned units, then return exactly one JSON object matching the contract at the end.',
|
|
709
|
+
'',
|
|
710
|
+
'## Architecture',
|
|
711
|
+
architecture.trim(),
|
|
712
|
+
'',
|
|
713
|
+
'## Your assigned units',
|
|
714
|
+
units.map(unitBlock).join('\n'),
|
|
715
|
+
'',
|
|
716
|
+
...classes.flatMap((c) => [`## Attack class: ${c.title}`, bullet(c.guide), '']),
|
|
717
|
+
'## Method',
|
|
718
|
+
bullet(HUNT_METHOD),
|
|
719
|
+
'',
|
|
720
|
+
'## Candidate gate',
|
|
721
|
+
bullet(CANDIDATE_GATE),
|
|
722
|
+
'',
|
|
723
|
+
'## Severity anchors (confirmed only)',
|
|
724
|
+
bullet(SEVERITY_ANCHORS),
|
|
725
|
+
'',
|
|
726
|
+
exclusions.length ? `## Already on record, do not report again\n${bullet(exclusions.slice(0, 20).map((e) => `${e.fingerprint}: ${e.title} (${e.why})`))}\n` : '',
|
|
727
|
+
peerIds ? `Other hunters own ${peerIds} other units this wave. Stay inside your assigned paths; put anything else in "uncovered".\n` : '',
|
|
728
|
+
'## Result contract',
|
|
729
|
+
'Every assigned coverage_id appears exactly once in "units". "covered" means you read the paths and the invariant holds. "candidate" lists the fingerprints of the candidates you return. "blocked" means you read the code but one fact you cannot see decides it; say which in "unresolved". Every check lists the paths it read.',
|
|
730
|
+
'Return only this JSON object, no prose before or after it:',
|
|
731
|
+
JSON.stringify(contract, null, 2),
|
|
732
|
+
].join('\n');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function withCode(root, trace) {
|
|
736
|
+
return (trace || []).map((t) => {
|
|
737
|
+
const text = t && isRepoRel(t.file) ? readText(root, t.file) : null;
|
|
738
|
+
const line = text && Number.isInteger(t.line) ? text.split('\n')[t.line - 1] : undefined;
|
|
739
|
+
return line === undefined ? t : { ...t, code: line.trim().slice(0, 200) };
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function verifierPrompt({ candidate, cls }) {
|
|
744
|
+
const shown = { ...candidate };
|
|
745
|
+
delete shown.found_by;
|
|
746
|
+
const contract = {
|
|
747
|
+
verdict: 'confirmed|needs_validation|rejected',
|
|
748
|
+
reason: 'what you read and why it holds or fails',
|
|
749
|
+
preventing_layer: 'file:line of the control that stops it, or null',
|
|
750
|
+
corrected: { title: '', root_cause: '', attacker: '', impact: '', trace: [{ kind: 'entrypoint|propagation|sink', file: '', line: 1, description: '' }], conditions: [], severity: { likelihood: { score: '', reason: '' }, impact: { score: '', reason: '' }, overall: '' }, remediation: { strategy: '', regression_test: '' } },
|
|
751
|
+
blockers: ['(needs_validation) the exact missing fact'],
|
|
752
|
+
validation_plan: [{ context: 'local|deployment', step: '' }],
|
|
753
|
+
};
|
|
754
|
+
return [
|
|
755
|
+
'You are an independent security verifier on the Atris sentinel team. A different model proposed the candidate below. Your job is to try to disprove it, then return exactly one JSON object.',
|
|
756
|
+
'',
|
|
757
|
+
'## How to verify',
|
|
758
|
+
bullet([
|
|
759
|
+
'Open every cited file and line yourself. The "code" field shows what that line holds now; check the claim against the code, not against the description.',
|
|
760
|
+
'Look for the control that would stop it: an earlier check, a caller that never passes lower-trust input, a type or schema that rejects the value, a wrapper that escapes it.',
|
|
761
|
+
'Ask who controls the input. If it is the same person who runs the tool, it is self-impact, so reject it.',
|
|
762
|
+
'"confirmed": the full trace holds, the impact crosses the stated boundary, and nothing in source prevents it. Fix the severity if it is overstated; overall can never exceed impact.',
|
|
763
|
+
'"needs_validation": the trace holds in source, but a decisive fact is outside it. Name that fact exactly and give a safe check the owner can run.',
|
|
764
|
+
'"rejected": the source disproves it. Name the line that does.',
|
|
765
|
+
'Use "corrected" only for fields you would change. Read-only: do not run the target or touch the network.',
|
|
766
|
+
]),
|
|
767
|
+
'',
|
|
768
|
+
`## Attack class: ${cls.title}`,
|
|
769
|
+
bullet(cls.guide),
|
|
770
|
+
'',
|
|
771
|
+
'## Severity anchors',
|
|
772
|
+
bullet(SEVERITY_ANCHORS),
|
|
773
|
+
'',
|
|
774
|
+
'## Candidate',
|
|
775
|
+
JSON.stringify(shown, null, 2),
|
|
776
|
+
'',
|
|
777
|
+
'## Result contract',
|
|
778
|
+
'Return only this JSON object:',
|
|
779
|
+
JSON.stringify(contract, null, 2),
|
|
780
|
+
].join('\n');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function recheckPrompt({ record }) {
|
|
784
|
+
const shown = { ...record };
|
|
785
|
+
delete shown.found_by;
|
|
786
|
+
delete shown.verified_by;
|
|
787
|
+
return [
|
|
788
|
+
'You are the final record checker on the Atris sentinel team. Two other models already agreed this is a real security problem. Read the record against the source one last time and return exactly one JSON object.',
|
|
789
|
+
'',
|
|
790
|
+
bullet([
|
|
791
|
+
'Open each cited file:line. Every sentence in the record must match what the code does now.',
|
|
792
|
+
'Check the severity against the anchors. Overall can never exceed impact.',
|
|
793
|
+
'If the record is right, return {"ok": true}.',
|
|
794
|
+
'If a detail is wrong but the problem is real, return {"ok": false, "verdict": "confirmed", "corrected": {...only the fields to change}}.',
|
|
795
|
+
'If the problem is not real after all, return {"ok": false, "verdict": "rejected", "reason": "..."} or {"ok": false, "verdict": "needs_validation", "blockers": [...], "validation_plan": [{"context": "local|deployment", "step": "..."}]}.',
|
|
796
|
+
]),
|
|
797
|
+
'',
|
|
798
|
+
'## Severity anchors',
|
|
799
|
+
bullet(SEVERITY_ANCHORS),
|
|
800
|
+
'',
|
|
801
|
+
'## Record',
|
|
802
|
+
JSON.stringify(shown, null, 2),
|
|
803
|
+
].join('\n');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function criticPrompt({ architecture, units, final }) {
|
|
807
|
+
const rows = units.filter((u) => u.status !== 'out_of_scope').slice(0, 160).map((u) => `${u.status.padEnd(11)} ${u.attack_class.padEnd(22)} ${u.starting_paths.slice(0, 3).join(', ')}${u.unresolved.length ? ` (unresolved: ${u.unresolved[0].slice(0, 80)})` : ''}`);
|
|
808
|
+
return [
|
|
809
|
+
`You are the ${final ? 'final' : ''} coverage critic on the Atris sentinel team. You propose coverage, not findings. Read the architecture and ledger, look at the source, and name real gaps.`,
|
|
810
|
+
'',
|
|
811
|
+
'## Architecture',
|
|
812
|
+
architecture.trim(),
|
|
813
|
+
'',
|
|
814
|
+
`## Ledger (${units.length} units, first 160 shown)`,
|
|
815
|
+
rows.join('\n'),
|
|
816
|
+
'',
|
|
817
|
+
'## What to look for',
|
|
818
|
+
bullet([
|
|
819
|
+
'Entry points that take outside input and have no unit (servers, webhooks, cli args read from files, model output used as input).',
|
|
820
|
+
'Sibling paths that reach the same sink as a covered unit but were never assigned.',
|
|
821
|
+
'Units marked covered or blocked whose result looks thin for the class.',
|
|
822
|
+
'Do not propose test files or docs.',
|
|
823
|
+
]),
|
|
824
|
+
'',
|
|
825
|
+
`Attack classes: ${CLASSES.map((c) => c.id).join(', ')}`,
|
|
826
|
+
'',
|
|
827
|
+
'Return only this JSON object:',
|
|
828
|
+
JSON.stringify({ missing_units: [{ surface: 'repo/relative/path', attack_class: 'one class id', starting_paths: ['repo/relative/path'], reason: 'the source fact that makes this a gap' }], reassign_ids: ['coverage_id of a unit whose result did not close it'], stop: false }, null, 2),
|
|
829
|
+
].join('\n');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ---------------------------------------------------------------- hunters
|
|
833
|
+
|
|
834
|
+
function cleanPaths(root, list) {
|
|
835
|
+
const out = [];
|
|
836
|
+
for (const raw of Array.isArray(list) ? list : []) {
|
|
837
|
+
const rel = normalizeRepoPath(root, raw);
|
|
838
|
+
if (rel && !out.includes(rel)) out.push(rel);
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function archiveAttempt(u, reason) {
|
|
844
|
+
u.attempts.push({ wave: u.wave, status: u.status, agent_id: u.agent_id, reviewed_paths: u.reviewed_paths, local_checks: u.local_checks, result_fingerprints: u.result_fingerprints, unresolved: u.unresolved, reassignment_reason: reason });
|
|
845
|
+
u.wave += 1;
|
|
846
|
+
resetUnit(u, 'planned');
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function resetUnit(u, status, reason = null) {
|
|
850
|
+
u.status = status;
|
|
851
|
+
u.agent_id = null;
|
|
852
|
+
u.reviewed_paths = [];
|
|
853
|
+
u.local_checks = [];
|
|
854
|
+
u.result_fingerprints = [];
|
|
855
|
+
u.unresolved = reason ? [reason] : [];
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Map one hunter answer onto its units. A malformed unit result leaves the
|
|
859
|
+
// unit planned; the run defers it with the reason instead of pretending.
|
|
860
|
+
function applyHunterResult(root, units, answer, agentId, engine) {
|
|
861
|
+
const parsed = answer && answer.ok !== false ? extractJson(answer.stdout, (v) => Array.isArray(v.units)) : null;
|
|
862
|
+
const failures = [];
|
|
863
|
+
const candidates = [];
|
|
864
|
+
const uncovered = [];
|
|
865
|
+
if (!parsed) {
|
|
866
|
+
const why = answer && answer.ok === false ? `hunter ${engine} failed: ${String(answer.reason || answer.stderr || 'no answer').slice(0, 120)}` : 'hunter returned no parseable result';
|
|
867
|
+
for (const u of units) { resetUnit(u, 'planned'); u.last_failure = why; }
|
|
868
|
+
return { candidates, uncovered, failures: units.map((u) => u.coverage_id), why };
|
|
869
|
+
}
|
|
870
|
+
const byId = new Map((parsed.units || []).filter((r) => r && r.coverage_id).map((r) => [r.coverage_id, r]));
|
|
871
|
+
const rawCandidates = (Array.isArray(parsed.candidates) ? parsed.candidates : []).filter((c) => c && FINGERPRINT_RE.test(String(c.fingerprint || '')));
|
|
872
|
+
const fpSet = new Set(rawCandidates.map((c) => c.fingerprint));
|
|
873
|
+
for (const u of units) {
|
|
874
|
+
const r = byId.get(u.coverage_id);
|
|
875
|
+
if (!r) { resetUnit(u, 'planned'); u.last_failure = 'hunter skipped this unit'; failures.push(u.coverage_id); continue; }
|
|
876
|
+
const checks = (Array.isArray(r.checks) ? r.checks : []).filter((c) => c && nonEmptyString(c.invariant) && nonEmptyString(c.result));
|
|
877
|
+
let reviewed = cleanPaths(root, r.reviewed_paths);
|
|
878
|
+
const normChecks = checks.map((c) => {
|
|
879
|
+
const paths = cleanPaths(root, c.reviewed_paths);
|
|
880
|
+
return { agent_id: agentId, reviewed_paths: paths.length ? paths : reviewed, invariant: String(c.invariant).slice(0, 400), method: 'source', result: String(c.result).slice(0, 800), artifact: null, engine };
|
|
881
|
+
}).filter((c) => c.reviewed_paths.length);
|
|
882
|
+
reviewed = [...new Set(normChecks.flatMap((c) => c.reviewed_paths))];
|
|
883
|
+
if (!reviewed.length || !normChecks.length) { resetUnit(u, 'planned'); u.last_failure = 'hunter result had no reviewed paths or checks'; failures.push(u.coverage_id); continue; }
|
|
884
|
+
const fps = (Array.isArray(r.candidate_fingerprints) ? r.candidate_fingerprints : []).filter((fp) => fpSet.has(fp));
|
|
885
|
+
const unresolved = (Array.isArray(r.unresolved) ? r.unresolved : []).filter(nonEmptyString).map((s) => s.slice(0, 300));
|
|
886
|
+
u.agent_id = agentId;
|
|
887
|
+
u.reviewed_paths = reviewed;
|
|
888
|
+
u.local_checks = normChecks;
|
|
889
|
+
if (r.disposition === 'candidate' && fps.length) {
|
|
890
|
+
u.status = 'candidate'; u.result_fingerprints = fps; u.unresolved = unresolved;
|
|
891
|
+
} else if (r.disposition === 'blocked' && unresolved.length) {
|
|
892
|
+
u.status = 'blocked'; u.result_fingerprints = []; u.unresolved = unresolved;
|
|
893
|
+
} else {
|
|
894
|
+
u.status = 'covered'; u.result_fingerprints = []; u.unresolved = [];
|
|
895
|
+
}
|
|
896
|
+
u.hardening = (Array.isArray(parsed.hardening) ? parsed.hardening : []).filter(nonEmptyString).slice(0, 5).map((s) => s.slice(0, 300));
|
|
897
|
+
delete u.last_failure;
|
|
898
|
+
}
|
|
899
|
+
const owned = new Set(units.map((u) => u.coverage_id));
|
|
900
|
+
for (const c of rawCandidates) {
|
|
901
|
+
const ids = (Array.isArray(c.coverage_ids) ? c.coverage_ids : []).filter((id) => owned.has(id));
|
|
902
|
+
const linked = units.filter((u) => u.result_fingerprints.includes(c.fingerprint)).map((u) => u.coverage_id);
|
|
903
|
+
candidates.push({ ...c, coverage_ids: [...new Set([...ids, ...linked])], found_by: { engine, agent_id: agentId } });
|
|
904
|
+
}
|
|
905
|
+
for (const x of Array.isArray(parsed.uncovered) ? parsed.uncovered : []) if (x) uncovered.push(x);
|
|
906
|
+
return { candidates, uncovered, failures };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// A proposed unit from a critic or a hunter's "uncovered" list becomes a real
|
|
910
|
+
// ledger unit only if its class exists and its paths are real, in scope files.
|
|
911
|
+
function proposedUnit(root, raw, { profile, scope }) {
|
|
912
|
+
const cls = CLASS_BY_ID.get(raw && raw.attack_class);
|
|
913
|
+
if (!cls) return null;
|
|
914
|
+
const paths = cleanPaths(root, [...(raw.starting_paths || []), raw.surface]).filter((p) => readText(root, p) !== null && !TEST_PATH.test(p) && inScope(p, scope));
|
|
915
|
+
if (!paths.length) return null;
|
|
916
|
+
const rel = paths[0];
|
|
917
|
+
const sub = subsystemOf(rel);
|
|
918
|
+
const quick = profile === 'quick';
|
|
919
|
+
const surface = quick ? `${sub}/*` : rel;
|
|
920
|
+
const refs = { surface, boundary: `classes#${cls.id}/boundary`, subsystem: quick ? 'profile/quick/all-in-scope-subsystems' : sub, attack_class: `classes#${cls.id}` };
|
|
921
|
+
const text = readText(root, rel) || '';
|
|
922
|
+
const hits = detectHits(text, cls);
|
|
923
|
+
const unit = newUnit({ refs, surface, boundary: cls.boundary, subsystem: sub, attackClass: cls.id, paths: quick ? paths.slice(0, MAX_QUICK_PATHS) : [rel], hot: hits.slice(0, MAX_HOT_LINES).map((h) => ({ file: rel, ...h })), priority: classPriority(cls, rel, hits.length) + 25, digest: sourceDigest(root, quick ? paths.slice(0, MAX_QUICK_PATHS) : [rel]) });
|
|
924
|
+
unit.prior_status = 'new';
|
|
925
|
+
unit.proposed_because = String(raw.reason || '').slice(0, 300);
|
|
926
|
+
return unit;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function addUnits(ledger, fresh) {
|
|
930
|
+
const ids = new Set(ledger.map((u) => u.coverage_id));
|
|
931
|
+
let added = 0;
|
|
932
|
+
for (const u of fresh) {
|
|
933
|
+
if (!u || ids.has(u.coverage_id)) continue;
|
|
934
|
+
ids.add(u.coverage_id);
|
|
935
|
+
ledger.push(u);
|
|
936
|
+
added += 1;
|
|
937
|
+
}
|
|
938
|
+
ledger.sort(cmpId);
|
|
939
|
+
return added;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ---------------------------------------------------------------- verdicts
|
|
943
|
+
|
|
944
|
+
function citationErrors(root, candidate) {
|
|
945
|
+
const errors = [];
|
|
946
|
+
const trace = Array.isArray(candidate.trace) ? candidate.trace : [];
|
|
947
|
+
if (!trace.length) errors.push('no trace');
|
|
948
|
+
for (const t of trace) {
|
|
949
|
+
const rel = normalizeRepoPath(root, t && t.file);
|
|
950
|
+
if (!rel) { errors.push(`unusable path ${t && t.file}`); continue; }
|
|
951
|
+
t.file = rel;
|
|
952
|
+
const line = Number(t.line);
|
|
953
|
+
t.line = line;
|
|
954
|
+
const text = readText(root, rel);
|
|
955
|
+
if (text === null) errors.push(`${rel} does not exist`);
|
|
956
|
+
else if (!Number.isInteger(line) || line < 1 || line > text.split('\n').length) errors.push(`${rel}:${t.line} is past the end of the file`);
|
|
957
|
+
}
|
|
958
|
+
return errors;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function fixTraceKinds(trace) {
|
|
962
|
+
if (!Array.isArray(trace) || trace.length < 2) return trace;
|
|
963
|
+
return trace.map((t, i) => ({ ...t, kind: i === 0 ? 'entrypoint' : i === trace.length - 1 ? 'sink' : 'propagation' }));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function clampSeverity(sev) {
|
|
967
|
+
if (!sev || !sev.impact || !SCORES.includes(sev.impact.score) || !SCORES.includes(sev.overall)) return sev;
|
|
968
|
+
if (SCORE_RANK[sev.overall] > SCORE_RANK[sev.impact.score]) return { ...sev, overall: sev.impact.score };
|
|
969
|
+
return sev;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function str(v, max = 2000) {
|
|
973
|
+
return typeof v === 'string' ? v.trim().slice(0, max) : '';
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function buildRecord(root, cand, verdict, extra) {
|
|
977
|
+
const base = {
|
|
978
|
+
fingerprint: cand.fingerprint,
|
|
979
|
+
title: str(cand.title, 200) || cand.fingerprint,
|
|
980
|
+
attack_class: CLASS_BY_ID.has(cand.attack_class) ? cand.attack_class : (cand._unit_class || 'command-injection'),
|
|
981
|
+
coverage_ids: cand.coverage_ids || [],
|
|
982
|
+
found_by: cand.found_by,
|
|
983
|
+
};
|
|
984
|
+
if (verdict === 'rejected') return { verdict, ...base, reason: str(extra.reason) || 'disproved by the verifier', rejected_by: extra.by };
|
|
985
|
+
const trace = withCode(root, fixTraceKinds(cand.trace));
|
|
986
|
+
if (verdict === 'needs_validation') {
|
|
987
|
+
const blockers = (extra.blockers || cand.blockers || []).filter(nonEmptyString).map((b) => str(b, 400));
|
|
988
|
+
const plan = (extra.validation_plan || cand.validation_plan || []).filter((s) => s && ['local', 'deployment'].includes(s.context) && nonEmptyString(s.step)).map((s) => ({ context: s.context, step: str(s.step, 400) }));
|
|
989
|
+
return {
|
|
990
|
+
verdict, ...base,
|
|
991
|
+
description: str(cand.description),
|
|
992
|
+
claimed_root_cause: str(cand.claimed_root_cause) || str(cand.root_cause) || str(cand.description),
|
|
993
|
+
trace,
|
|
994
|
+
blockers: blockers.length ? blockers : [str(extra.reason, 400) || 'a fact outside source decides this'],
|
|
995
|
+
validation_plan: plan.length ? plan : [{ context: 'deployment', step: 'confirm the missing fact named in the blocker with the owner of that system' }],
|
|
996
|
+
verified_by: extra.by ? [extra.by] : [],
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
return {
|
|
1000
|
+
verdict, ...base,
|
|
1001
|
+
description: str(cand.description),
|
|
1002
|
+
root_cause: str(cand.root_cause) || str(cand.claimed_root_cause),
|
|
1003
|
+
intended_behavior: str(cand.intended_behavior),
|
|
1004
|
+
attacker: str(cand.attacker),
|
|
1005
|
+
impact: str(cand.impact),
|
|
1006
|
+
trace,
|
|
1007
|
+
conditions: (cand.conditions || []).filter((c) => c && CONDITION_KINDS.includes(c.kind) && nonEmptyString(c.description)).map((c) => ({ kind: c.kind, description: str(c.description, 400) })),
|
|
1008
|
+
severity: clampSeverity(cand.severity),
|
|
1009
|
+
remediation: { strategy: str(cand.remediation && cand.remediation.strategy), regression_test: str(cand.remediation && cand.remediation.regression_test) },
|
|
1010
|
+
evidence_level: 'source-traced, not executed',
|
|
1011
|
+
verified_by: extra.by ? [extra.by] : [],
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function mergeCorrected(cand, corrected) {
|
|
1016
|
+
if (!corrected || typeof corrected !== 'object') return cand;
|
|
1017
|
+
const out = { ...cand };
|
|
1018
|
+
for (const k of ['title', 'root_cause', 'attacker', 'impact', 'description', 'intended_behavior']) if (nonEmptyString(corrected[k])) out[k] = corrected[k];
|
|
1019
|
+
if (Array.isArray(corrected.trace) && corrected.trace.length && corrected.trace.every((t) => t && t.file && t.line)) out.trace = corrected.trace;
|
|
1020
|
+
if (Array.isArray(corrected.conditions) && corrected.conditions.length) out.conditions = corrected.conditions;
|
|
1021
|
+
const s = corrected.severity;
|
|
1022
|
+
if (s && s.impact && SCORES.includes(s.impact.score) && SCORES.includes(s.overall) && s.likelihood && SCORES.includes(s.likelihood.score)) out.severity = s;
|
|
1023
|
+
if (corrected.remediation && nonEmptyString(corrected.remediation.strategy)) out.remediation = corrected.remediation;
|
|
1024
|
+
return out;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Turn a verifier answer into a final record. Anything unparseable keeps the
|
|
1028
|
+
// candidate out of findings; a confirmed verdict with a broken record falls
|
|
1029
|
+
// back to needs_validation rather than shipping a record that fails the
|
|
1030
|
+
// contract.
|
|
1031
|
+
function decide(root, cand, answer, by) {
|
|
1032
|
+
const parsed = answer && answer.ok !== false ? extractJson(answer.stdout, (v) => typeof v.verdict === 'string') : null;
|
|
1033
|
+
if (!parsed || !['confirmed', 'needs_validation', 'rejected'].includes(parsed.verdict)) return null;
|
|
1034
|
+
const merged = mergeCorrected(cand, parsed.corrected);
|
|
1035
|
+
if (parsed.verdict === 'rejected') return buildRecord(root, merged, 'rejected', { reason: parsed.preventing_layer ? `${str(parsed.reason, 600)} (stopped at ${str(parsed.preventing_layer, 200)})` : parsed.reason, by });
|
|
1036
|
+
if (citationErrors(root, merged).length) return buildRecord(root, cand, 'rejected', { reason: `verifier cited source that does not resolve: ${citationErrors(root, merged).join('; ')}`, by });
|
|
1037
|
+
if (parsed.verdict === 'needs_validation') return buildRecord(root, merged, 'needs_validation', { blockers: parsed.blockers, validation_plan: parsed.validation_plan, reason: parsed.reason, by });
|
|
1038
|
+
const rec = buildRecord(root, merged, 'confirmed', { by: { ...by, reason: str(parsed.reason, 600) } });
|
|
1039
|
+
const errs = validateFindings([rec], { root });
|
|
1040
|
+
if (errs.length) {
|
|
1041
|
+
return buildRecord(root, merged, 'needs_validation', { blockers: [`the confirmed record was incomplete: ${errs.slice(0, 3).join('; ')}`], validation_plan: [{ context: 'local', step: str(merged.remediation && merged.remediation.regression_test, 400) || 'write the regression test the record describes and see it fail' }], by });
|
|
1042
|
+
}
|
|
1043
|
+
return rec;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ---------------------------------------------------------------- reports
|
|
1047
|
+
|
|
1048
|
+
function sevCounts(records) {
|
|
1049
|
+
const c = { critical: 0, high: 0, medium: 0, low: 0, informational: 0 };
|
|
1050
|
+
for (const r of records) if (r.verdict === 'confirmed' && r.severity) c[r.severity.overall] = (c[r.severity.overall] || 0) + 1;
|
|
1051
|
+
return c;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function coverageCounts(ledger) {
|
|
1055
|
+
const c = {};
|
|
1056
|
+
for (const u of ledger) c[u.status] = (c[u.status] || 0) + 1;
|
|
1057
|
+
const inScope = ledger.filter((u) => u.status !== 'out_of_scope').length;
|
|
1058
|
+
const done = (c.covered || 0) + (c.candidate || 0);
|
|
1059
|
+
const carried = ledger.filter((u) => u.carried_from).length;
|
|
1060
|
+
return { ...c, in_scope: inScope, done, carried, fresh: done - carried };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function plural(n, one, many = `${one}s`) {
|
|
1064
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function renderReports(dir, { meta, ledger, records, carriedPending }) {
|
|
1068
|
+
const confirmed = records.filter((r) => r.verdict === 'confirmed').sort((a, b) => SCORE_RANK[b.severity.overall] - SCORE_RANK[a.severity.overall] || a.fingerprint.localeCompare(b.fingerprint));
|
|
1069
|
+
const needs = records.filter((r) => r.verdict === 'needs_validation');
|
|
1070
|
+
const rejected = records.filter((r) => r.verdict === 'rejected');
|
|
1071
|
+
const cov = coverageCounts(ledger);
|
|
1072
|
+
const sev = sevCounts(records);
|
|
1073
|
+
const hardening = [...new Set(ledger.flatMap((u) => u.hardening || []))].slice(0, 20);
|
|
1074
|
+
const report = [
|
|
1075
|
+
`# Security audit: ${meta.repo}`,
|
|
1076
|
+
'',
|
|
1077
|
+
meta.run_status === 'incomplete' ? `This run is incomplete: ${meta.incomplete_reason.replace(/_/g, ' ')}.${carriedPending.length ? ` These records were not re-checked: ${carriedPending.join(', ')}.` : ''}` : '',
|
|
1078
|
+
'',
|
|
1079
|
+
`${plural(confirmed.length, 'confirmed problem')}${confirmed.length ? ` (${SCORES.slice().reverse().filter((s) => sev[s]).map((s) => `${sev[s]} ${s}`).join(', ')})` : ''}. ${plural(needs.length, 'lead needs', 'leads need')} a fact only the owner can check. ${rejected.length} disproved.`,
|
|
1080
|
+
'',
|
|
1081
|
+
`Covered ${cov.done} of ${cov.in_scope} areas (${cov.fresh} read this run, ${cov.carried} carried from an unchanged earlier run). ${cov.deferred || 0} wait for the next run.${meta.coverage_clean ? ' The final coverage critic found no gaps.' : ''}`,
|
|
1082
|
+
'',
|
|
1083
|
+
`Found by ${meta.engines.hunter}, checked by ${meta.engines.verifier}${meta.engines.hunter === meta.engines.verifier ? ' (same engine, fresh process; pick a different verifier for full independence)' : ', a different model'}. Every record was read from source, not executed.`,
|
|
1084
|
+
'',
|
|
1085
|
+
'## Confirmed',
|
|
1086
|
+
'',
|
|
1087
|
+
confirmed.length ? confirmed.map((r) => `- ${r.severity.overall}: ${r.title} (${r.trace[r.trace.length - 1].file}:${r.trace[r.trace.length - 1].line}). Fix: ${r.remediation.strategy}`).join('\n') : 'none',
|
|
1088
|
+
'',
|
|
1089
|
+
'## Needs validation',
|
|
1090
|
+
'',
|
|
1091
|
+
needs.length ? needs.map((r) => `- ${r.title}. Blocked on: ${r.blockers[0]}`).join('\n') : 'none',
|
|
1092
|
+
'',
|
|
1093
|
+
'## Could not settle',
|
|
1094
|
+
'',
|
|
1095
|
+
ledger.filter((u) => u.status === 'blocked').length ? ledger.filter((u) => u.status === 'blocked').map((u) => `- ${u.attack_class} in ${u.starting_paths.join(', ')}: ${u.unresolved[0]}`).join('\n') : 'none',
|
|
1096
|
+
'',
|
|
1097
|
+
'## Hardening notes (not findings)',
|
|
1098
|
+
'',
|
|
1099
|
+
hardening.length ? hardening.map((h) => `- ${h}`).join('\n') : 'none',
|
|
1100
|
+
'',
|
|
1101
|
+
'## Coverage',
|
|
1102
|
+
'',
|
|
1103
|
+
`- profile: ${meta.profile}${meta.scope_paths.length ? `, scoped to ${meta.scope_paths.join(', ')}` : ''}`,
|
|
1104
|
+
`- units: ${Object.entries(cov).filter(([k]) => STATUSES.includes(k)).map(([k, v]) => `${k} ${v}`).join(', ')}`,
|
|
1105
|
+
`- engine calls: ${meta.invocations.used} of ${meta.budget}`,
|
|
1106
|
+
`- source: ${meta.source_ref.commit || 'not a git repo'}${meta.source_ref.dirty ? ' (uncommitted changes present)' : ''}`,
|
|
1107
|
+
`- prior run: ${meta.prior_run || 'none'}`,
|
|
1108
|
+
'',
|
|
1109
|
+
'One run never covers everything. Run again to continue: unchanged covered areas are skipped and the budget goes to what is left.',
|
|
1110
|
+
'',
|
|
1111
|
+
].filter((l, i, arr) => !(l === '' && arr[i - 1] === '')).join('\n');
|
|
1112
|
+
|
|
1113
|
+
const detail = ['# Findings detail', ''];
|
|
1114
|
+
for (const r of confirmed) {
|
|
1115
|
+
detail.push(`## ${r.title}`, '', `- severity: ${r.severity.overall} (likelihood ${r.severity.likelihood.score}: ${r.severity.likelihood.reason}; impact ${r.severity.impact.score}: ${r.severity.impact.reason})`, `- fingerprint: ${r.fingerprint}`, `- class: ${r.attack_class}`, `- who can do it: ${r.attacker}`, `- what happens: ${r.impact}`, `- root cause: ${r.root_cause}`, `- should instead: ${r.intended_behavior}`, `- found by: ${r.found_by ? `${r.found_by.engine} (${r.found_by.agent_id})` : 'carried'}; checked by: ${r.verified_by.map((v) => `${v.engine} (${v.agent_id})`).join(', ')}`, '', 'Trace:', '');
|
|
1116
|
+
for (const t of r.trace) detail.push(`- ${t.kind} ${t.file}:${t.line}: ${t.description}${t.code ? `\n \`${t.code.replace(/`/g, "'")}\`` : ''}`);
|
|
1117
|
+
if (r.conditions.length) detail.push('', 'Conditions:', '', ...r.conditions.map((c) => `- ${c.kind}: ${c.description}`));
|
|
1118
|
+
detail.push('', `Fix: ${r.remediation.strategy}`, r.remediation.regression_test ? `Regression test: ${r.remediation.regression_test}` : '', '');
|
|
1119
|
+
}
|
|
1120
|
+
if (!confirmed.length) detail.push('No confirmed findings this run.', '');
|
|
1121
|
+
|
|
1122
|
+
const nv = ['# Needs validation', '', 'Each lead below holds in source, but one fact outside the code decides it. None has a severity.', ''];
|
|
1123
|
+
for (const r of needs) {
|
|
1124
|
+
nv.push(`## ${r.title}`, '', `- fingerprint: ${r.fingerprint}`, `- suspected root cause: ${r.claimed_root_cause}`, '', 'Blocked on:', '', ...r.blockers.map((b) => `- ${b}`), '', 'How to check safely:', '', ...r.validation_plan.map((s) => `- ${s.context}: ${s.step}`), '', 'Trace:', '', ...r.trace.map((t) => `- ${t.kind} ${t.file}:${t.line}: ${t.description}`), '');
|
|
1125
|
+
}
|
|
1126
|
+
if (!needs.length) nv.push('Nothing waiting on an outside fact.', '');
|
|
1127
|
+
|
|
1128
|
+
fs.writeFileSync(path.join(dir, 'REPORT.md'), report);
|
|
1129
|
+
fs.writeFileSync(path.join(dir, 'FINDINGS-DETAIL.md'), `${detail.filter((l) => l !== undefined).join('\n')}\n`);
|
|
1130
|
+
fs.writeFileSync(path.join(dir, 'NEEDS-VALIDATION.md'), `${nv.join('\n')}\n`);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ---------------------------------------------------------------- run
|
|
1134
|
+
|
|
1135
|
+
// The nearest existing ancestor, realpath'd, plus the rest: symlinked
|
|
1136
|
+
// parents cannot hide that a folder is really inside the target.
|
|
1137
|
+
function realish(p) {
|
|
1138
|
+
const rest = [];
|
|
1139
|
+
let cur = path.resolve(p);
|
|
1140
|
+
while (!fs.existsSync(cur) && path.dirname(cur) !== cur) { rest.unshift(path.basename(cur)); cur = path.dirname(cur); }
|
|
1141
|
+
let real = cur;
|
|
1142
|
+
try { real = fs.realpathSync(cur); } catch {}
|
|
1143
|
+
return path.join(real, ...rest);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function resolveOutDir(root, { out, repo }) {
|
|
1147
|
+
const rootReal = realRoot(root);
|
|
1148
|
+
if (out) {
|
|
1149
|
+
const abs = path.resolve(out);
|
|
1150
|
+
const inside = containedIn(rootReal, realish(abs));
|
|
1151
|
+
const rel = relPosix(path.relative(rootReal, realish(abs)));
|
|
1152
|
+
if (inside) {
|
|
1153
|
+
let ignored = false;
|
|
1154
|
+
try { execFileSync('git', ['check-ignore', '-q', rel || '.'], { cwd: root, stdio: 'ignore' }); ignored = true; } catch {}
|
|
1155
|
+
if (!ignored) throw new Error(`--out ${out} is inside the target and not ignored by git; pick a folder outside it`);
|
|
1156
|
+
}
|
|
1157
|
+
if (fs.existsSync(abs) && fs.readdirSync(abs).length) throw new Error(`--out ${out} already has files; pick a new folder`);
|
|
1158
|
+
return { dir: abs, outRoot: path.dirname(abs) };
|
|
1159
|
+
}
|
|
1160
|
+
const outRoot = path.join(auditHome(), repo);
|
|
1161
|
+
if (containedIn(rootReal, realish(outRoot))) throw new Error(`audit home ${outRoot} is inside the target; set ATRIS_SECURITY_AUDIT_HOME outside it`);
|
|
1162
|
+
const next = (runDirs(outRoot).pop() || { num: 0 }).num + 1;
|
|
1163
|
+
return { dir: path.join(outRoot, `run-${next}`), outRoot };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function budgetPlan(profile, total) {
|
|
1167
|
+
const critics = profile === 'quick' ? 1 : 2;
|
|
1168
|
+
const verifyReserve = Math.max(1, Math.ceil(0.3 * (total - critics)));
|
|
1169
|
+
const hunters = total - critics - verifyReserve;
|
|
1170
|
+
return { total, critics, verifyReserve, hunters };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function pickBatches(ledger, profile, allowance) {
|
|
1174
|
+
const planned = ledger.filter((u) => u.status === 'planned' && !u.reserved_for).sort((a, b) => b.priority - a.priority || cmpId(a, b));
|
|
1175
|
+
const size = UNITS_PER_HUNTER[profile];
|
|
1176
|
+
const groups = new Map();
|
|
1177
|
+
const order = [];
|
|
1178
|
+
for (const u of planned) {
|
|
1179
|
+
const key = `${u.attack_class}\0${u.subsystem}`;
|
|
1180
|
+
if (!groups.has(key)) { groups.set(key, []); order.push(key); }
|
|
1181
|
+
groups.get(key).push(u);
|
|
1182
|
+
}
|
|
1183
|
+
const batches = [];
|
|
1184
|
+
// Round-robin across class x subsystem groups in priority order, so one
|
|
1185
|
+
// noisy class cannot eat the whole budget.
|
|
1186
|
+
while (batches.length < allowance) {
|
|
1187
|
+
let took = false;
|
|
1188
|
+
for (const key of order) {
|
|
1189
|
+
if (batches.length >= allowance) break;
|
|
1190
|
+
const g = groups.get(key);
|
|
1191
|
+
if (!g.length) continue;
|
|
1192
|
+
batches.push(g.splice(0, size));
|
|
1193
|
+
took = true;
|
|
1194
|
+
}
|
|
1195
|
+
if (!took) break;
|
|
1196
|
+
}
|
|
1197
|
+
return batches;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function runAudit(opts = {}) {
|
|
1201
|
+
const root = path.resolve(opts.root || process.cwd());
|
|
1202
|
+
const profile = PROFILES.includes(opts.profile) ? opts.profile : 'standard';
|
|
1203
|
+
const scope = (opts.scope || []).map((s) => normalizeRepoPath(root, s)).filter(Boolean);
|
|
1204
|
+
if ((opts.scope || []).length && !scope.length) throw new Error('scope paths must be inside the target');
|
|
1205
|
+
const hunter = opts.hunter || 'codex';
|
|
1206
|
+
const verifier = opts.verifier || 'claude';
|
|
1207
|
+
const total = Number.isInteger(opts.budget) ? opts.budget : DEFAULT_BUDGET[profile];
|
|
1208
|
+
const runJobs = opts.runJobs || defaultRunJobs;
|
|
1209
|
+
const concurrency = opts.concurrency || 3;
|
|
1210
|
+
const timeoutMs = opts.timeoutMs || 15 * 60 * 1000;
|
|
1211
|
+
const log = opts.log || (() => {});
|
|
1212
|
+
const now = opts.now || (() => new Date());
|
|
1213
|
+
const repo = repoName(root);
|
|
1214
|
+
const { dir, outRoot } = resolveOutDir(root, { out: opts.out, repo });
|
|
1215
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1216
|
+
|
|
1217
|
+
// Phase 1: map.
|
|
1218
|
+
log(`mapping ${repo}`);
|
|
1219
|
+
const mapped = mapTarget(root, { profile, scope });
|
|
1220
|
+
const ledger = mapped.units;
|
|
1221
|
+
const prior = loadPrior(outRoot, dir);
|
|
1222
|
+
const { carried, exclusions } = mergePrior(ledger, prior);
|
|
1223
|
+
let scanSeeds = [];
|
|
1224
|
+
try {
|
|
1225
|
+
const { runScan } = require('./security-scan');
|
|
1226
|
+
scanSeeds = runScan({ root }).findings.filter((f) => f.sev === 'critical' || f.sev === 'high').filter((f) => inScope(f.file, scope));
|
|
1227
|
+
for (const f of scanSeeds) for (const u of ledger) if (u.starting_paths.includes(f.file) && u.attack_class === 'secrets-handling') u.priority += 20;
|
|
1228
|
+
} catch {}
|
|
1229
|
+
const architecture = architectureText({ repo, root, stats: mapped.stats, units: ledger, scope, profile, prior: prior && { dir: prior.dir, units: prior.units, findings: prior.findingsCount }, scanSeeds });
|
|
1230
|
+
const meta = {
|
|
1231
|
+
run_id: path.basename(dir),
|
|
1232
|
+
repo,
|
|
1233
|
+
target: root,
|
|
1234
|
+
source_ref: gitSourceRef(root),
|
|
1235
|
+
profile,
|
|
1236
|
+
scope_paths: scope,
|
|
1237
|
+
budget: total,
|
|
1238
|
+
execution_policy: 'source-only, read-only engines, no target execution, no network probes',
|
|
1239
|
+
engines: { hunter, verifier, critic: verifier },
|
|
1240
|
+
prior_run: prior ? prior.dir : null,
|
|
1241
|
+
started_at: now().toISOString(),
|
|
1242
|
+
run_status: 'in_progress',
|
|
1243
|
+
invocations: { used: 0, hunters: 0, critics: 0, verifiers: 0, rechecks: 0 },
|
|
1244
|
+
method: 'adapted from cloudflare/security-audit-skill (MIT)',
|
|
1245
|
+
};
|
|
1246
|
+
const ledgerFile = path.join(dir, 'coverage-ledger.json');
|
|
1247
|
+
const saveLedger = () => {
|
|
1248
|
+
ledger.sort(cmpId);
|
|
1249
|
+
const errs = validateLedger(ledger);
|
|
1250
|
+
if (errs.length) throw new Error(`coverage ledger failed validation: ${errs.slice(0, 5).join('; ')}`);
|
|
1251
|
+
writeJson(ledgerFile, ledger);
|
|
1252
|
+
};
|
|
1253
|
+
fs.writeFileSync(path.join(dir, 'architecture.md'), architecture);
|
|
1254
|
+
saveLedger();
|
|
1255
|
+
writeJson(path.join(dir, 'run-metadata.json'), meta);
|
|
1256
|
+
|
|
1257
|
+
const plan = budgetPlan(profile, total);
|
|
1258
|
+
const plannedCount = ledger.filter((u) => u.status === 'planned').length;
|
|
1259
|
+
meta.plan = { ...plan, planned_units: plannedCount, units_per_hunter: UNITS_PER_HUNTER[profile], hunters_needed: Math.ceil(plannedCount / UNITS_PER_HUNTER[profile]) };
|
|
1260
|
+
|
|
1261
|
+
const pendingRecords = [];
|
|
1262
|
+
const finish = (status, reason, records = [], carriedPending = []) => {
|
|
1263
|
+
writeJson(path.join(dir, 'carried-pending.json'), pendingRecords);
|
|
1264
|
+
meta.run_status = status;
|
|
1265
|
+
if (reason) meta.incomplete_reason = reason;
|
|
1266
|
+
meta.finished_at = now().toISOString();
|
|
1267
|
+
meta.coverage = coverageCounts(ledger);
|
|
1268
|
+
meta.counts = { confirmed: records.filter((r) => r.verdict === 'confirmed').length, needs_validation: records.filter((r) => r.verdict === 'needs_validation').length, rejected: records.filter((r) => r.verdict === 'rejected').length, severity: sevCounts(records) };
|
|
1269
|
+
saveLedger();
|
|
1270
|
+
writeJson(path.join(dir, 'findings.json'), records);
|
|
1271
|
+
writeJson(path.join(dir, 'run-metadata.json'), meta);
|
|
1272
|
+
renderReports(dir, { meta, ledger, records, carriedPending });
|
|
1273
|
+
return { dir, meta, ledger, records };
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
const deferAll = (why) => {
|
|
1277
|
+
for (const u of ledger) if (u.status === 'planned') { delete u.reserved_for; u.status = 'deferred'; u.unresolved = [why]; }
|
|
1278
|
+
pendingRecords.push(...carried.map(stripCarried));
|
|
1279
|
+
};
|
|
1280
|
+
if (opts.planOnly) {
|
|
1281
|
+
deferAll('plan only: no engines were called');
|
|
1282
|
+
return finish('incomplete', 'plan_only', [], carried.map((r) => r.fingerprint));
|
|
1283
|
+
}
|
|
1284
|
+
if (plan.hunters < 1) {
|
|
1285
|
+
deferAll('budget_cannot_fund_hunters_and_reserves');
|
|
1286
|
+
return finish('incomplete', 'budget_cannot_fund_hunters_and_reserves', [], carried.map((r) => r.fingerprint));
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const call = async (kind, jobs) => {
|
|
1290
|
+
if (!jobs.length) return [];
|
|
1291
|
+
meta.invocations.used += jobs.length;
|
|
1292
|
+
meta.invocations[kind] += jobs.length;
|
|
1293
|
+
writeJson(path.join(dir, 'run-metadata.json'), meta);
|
|
1294
|
+
const answers = await runJobs(jobs, { root, concurrency, timeoutMs });
|
|
1295
|
+
return answers;
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
// Phase 2 and 3: hunting waves, each followed by a coverage critic.
|
|
1299
|
+
const candidates = new Map();
|
|
1300
|
+
let huntLeft = plan.hunters;
|
|
1301
|
+
let criticsLeft = plan.critics;
|
|
1302
|
+
let coverageClean = false;
|
|
1303
|
+
const failures = [];
|
|
1304
|
+
const runCritic = async (wave, final) => {
|
|
1305
|
+
criticsLeft -= 1;
|
|
1306
|
+
const [answer] = await call('critics', [{ engine: verifier, prompt: criticPrompt({ architecture, units: ledger, final }), label: `sentinel critic ${final ? 'final' : `w${wave}`}` }]);
|
|
1307
|
+
const parsed = answer && answer.ok !== false ? extractJson(answer.stdout, (v) => Array.isArray(v.missing_units) || Array.isArray(v.reassign_ids)) : null;
|
|
1308
|
+
if (!parsed) return { accepted: 0, failed: true };
|
|
1309
|
+
const fresh = (parsed.missing_units || []).map((raw) => proposedUnit(root, raw, { profile, scope })).filter(Boolean);
|
|
1310
|
+
let accepted = addUnits(ledger, fresh);
|
|
1311
|
+
for (const id of parsed.reassign_ids || []) {
|
|
1312
|
+
const u = ledger.find((x) => x.coverage_id === id);
|
|
1313
|
+
if (u && ['covered', 'blocked'].includes(u.status) && !u.carried_from) { archiveAttempt(u, `critic: result did not close the unit (${final ? 'final' : `wave ${wave}`})`); accepted += 1; }
|
|
1314
|
+
}
|
|
1315
|
+
log(`critic ${final ? 'final' : `wave ${wave}`}: ${accepted} gap${accepted === 1 ? '' : 's'}`);
|
|
1316
|
+
return { accepted, failed: false };
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
for (let wave = 1; wave <= MAX_WAVES[profile]; wave++) {
|
|
1320
|
+
const batches = pickBatches(ledger, profile, huntLeft);
|
|
1321
|
+
if (!batches.length) break;
|
|
1322
|
+
huntLeft -= batches.length;
|
|
1323
|
+
const waveUnits = batches.reduce((n, b) => n + b.length, 0);
|
|
1324
|
+
const jobs = batches.map((units, i) => {
|
|
1325
|
+
const agentId = `h${wave}-${String(i + 1).padStart(2, '0')}`;
|
|
1326
|
+
for (const u of units) { u.status = 'in_progress'; u.agent_id = agentId; u.wave = wave; }
|
|
1327
|
+
return { engine: hunter, prompt: hunterPrompt({ architecture, units, exclusions, peerIds: waveUnits - units.length }), label: `sentinel hunter ${agentId}`, _units: units, _agent: agentId };
|
|
1328
|
+
});
|
|
1329
|
+
saveLedger();
|
|
1330
|
+
log(`wave ${wave}: ${jobs.length} hunter${jobs.length === 1 ? '' : 's'} on ${jobs.reduce((n, j) => n + j._units.length, 0)} areas (${hunter})`);
|
|
1331
|
+
const answers = await call('hunters', jobs.map(({ engine, prompt, label }) => ({ engine, prompt, label })));
|
|
1332
|
+
const uncovered = [];
|
|
1333
|
+
answers.forEach((answer, i) => {
|
|
1334
|
+
const res = applyHunterResult(root, jobs[i]._units, answer, jobs[i]._agent, hunter);
|
|
1335
|
+
for (const c of res.candidates) {
|
|
1336
|
+
if (candidates.has(c.fingerprint)) {
|
|
1337
|
+
const prev = candidates.get(c.fingerprint);
|
|
1338
|
+
prev.coverage_ids = [...new Set([...prev.coverage_ids, ...c.coverage_ids])];
|
|
1339
|
+
} else {
|
|
1340
|
+
c._unit_class = jobs[i]._units[0].attack_class;
|
|
1341
|
+
candidates.set(c.fingerprint, c);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
uncovered.push(...res.uncovered);
|
|
1345
|
+
if (res.failures.length) failures.push(...res.failures.map((id) => ({ id, why: res.why || 'malformed unit result' })));
|
|
1346
|
+
});
|
|
1347
|
+
addUnits(ledger, uncovered.map((raw) => proposedUnit(root, raw, { profile, scope })).filter(Boolean));
|
|
1348
|
+
saveLedger();
|
|
1349
|
+
if (profile === 'quick') {
|
|
1350
|
+
if (criticsLeft > 0) {
|
|
1351
|
+
const before = new Set(ledger.map((u) => u.coverage_id));
|
|
1352
|
+
await runCritic(wave, true);
|
|
1353
|
+
for (const u of ledger) if (u.status === 'planned' && (!before.has(u.coverage_id) || u.attempts.length)) { u.status = 'deferred'; u.unresolved = ['quick_profile_final_critic']; }
|
|
1354
|
+
}
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
const lastWave = wave === MAX_WAVES[profile];
|
|
1358
|
+
if (criticsLeft > 1 && !lastWave) {
|
|
1359
|
+
const res = await runCritic(wave, false);
|
|
1360
|
+
if (!res.failed && res.accepted === 0 && !ledger.some((u) => u.status === 'planned')) break;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (profile !== 'quick' && criticsLeft > 0 && !ledger.some((u) => u.status === 'planned')) {
|
|
1364
|
+
const res = await runCritic(MAX_WAVES[profile], true);
|
|
1365
|
+
coverageClean = !res.failed && res.accepted === 0;
|
|
1366
|
+
}
|
|
1367
|
+
for (const u of ledger) {
|
|
1368
|
+
if ((u.status === 'planned' && !u.reserved_for) || u.status === 'in_progress') {
|
|
1369
|
+
const why = u.last_failure || (huntLeft <= 0 ? 'budget_cannot_reserve_critics_and_validation' : 'wave_limit_reached');
|
|
1370
|
+
resetUnit(u, 'deferred', why);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
saveLedger();
|
|
1374
|
+
|
|
1375
|
+
// Phase 4: verify every candidate with a different model, cheapest net first.
|
|
1376
|
+
const queue = [...carried.map((r) => ({ ...r, _carried: true })), ...candidates.values()].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint));
|
|
1377
|
+
const records = [];
|
|
1378
|
+
const pending = [];
|
|
1379
|
+
const toVerify = [];
|
|
1380
|
+
const seenFp = new Set();
|
|
1381
|
+
for (const cand of queue) {
|
|
1382
|
+
if (seenFp.has(cand.fingerprint)) continue;
|
|
1383
|
+
seenFp.add(cand.fingerprint);
|
|
1384
|
+
if (!cand.attack_class || !CLASS_BY_ID.has(cand.attack_class)) cand.attack_class = cand._unit_class || 'command-injection';
|
|
1385
|
+
const errs = citationErrors(root, cand);
|
|
1386
|
+
if (errs.length) {
|
|
1387
|
+
records.push(buildRecord(root, cand, 'rejected', { reason: `cited source does not resolve: ${errs.slice(0, 3).join('; ')}`, by: { engine: 'atris', agent_id: 'citation-check' } }));
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
toVerify.push(cand);
|
|
1391
|
+
}
|
|
1392
|
+
const verifyBudget = Math.max(0, total - meta.invocations.used);
|
|
1393
|
+
const verifyNow = toVerify.slice(0, verifyBudget);
|
|
1394
|
+
const notVerified = toVerify.slice(verifyBudget);
|
|
1395
|
+
log(`verifying ${verifyNow.length} candidate${verifyNow.length === 1 ? '' : 's'} with ${verifier}`);
|
|
1396
|
+
const vJobs = verifyNow.map((cand, i) => ({ engine: verifier, prompt: verifierPrompt({ candidate: { ...cand, trace: withCode(root, cand.trace), _carried: undefined, _unit_class: undefined, carried_from: undefined, verified_by: undefined }, cls: CLASS_BY_ID.get(cand.attack_class) }), label: `sentinel verifier v-${String(i + 1).padStart(2, '0')}` }));
|
|
1397
|
+
const vAnswers = await call('verifiers', vJobs);
|
|
1398
|
+
vAnswers.forEach((answer, i) => {
|
|
1399
|
+
const cand = verifyNow[i];
|
|
1400
|
+
const by = { engine: verifier, agent_id: `v-${String(i + 1).padStart(2, '0')}` };
|
|
1401
|
+
if (cand._carried && !cand.found_by) cand.found_by = { engine: 'carried', agent_id: 'prior-run' };
|
|
1402
|
+
const rec = decide(root, cand, answer, by);
|
|
1403
|
+
if (rec) records.push(rec);
|
|
1404
|
+
else pending.push(cand.fingerprint);
|
|
1405
|
+
});
|
|
1406
|
+
for (const cand of notVerified) pending.push(cand.fingerprint);
|
|
1407
|
+
|
|
1408
|
+
// Phase 5: one more fresh read of each confirmed record (standard, deep).
|
|
1409
|
+
if (profile !== 'quick') {
|
|
1410
|
+
const confirmed = records.filter((r) => r.verdict === 'confirmed');
|
|
1411
|
+
const room = Math.max(0, total - meta.invocations.used);
|
|
1412
|
+
const checkNow = confirmed.slice(0, room);
|
|
1413
|
+
const rJobs = checkNow.map((rec, i) => ({ engine: verifier, prompt: recheckPrompt({ record: rec }), label: `sentinel recheck r-${String(i + 1).padStart(2, '0')}` }));
|
|
1414
|
+
const rAnswers = await call('rechecks', rJobs);
|
|
1415
|
+
rAnswers.forEach((answer, i) => {
|
|
1416
|
+
const rec = checkNow[i];
|
|
1417
|
+
const by = { engine: verifier, agent_id: `r-${String(i + 1).padStart(2, '0')}` };
|
|
1418
|
+
const parsed = answer && answer.ok !== false ? extractJson(answer.stdout, (v) => typeof v.ok === 'boolean') : null;
|
|
1419
|
+
const idx = records.indexOf(rec);
|
|
1420
|
+
if (!parsed) { rec.record_check = 'no answer'; pending.push(rec.fingerprint); records.splice(idx, 1); return; }
|
|
1421
|
+
if (parsed.ok) { rec.verified_by.push({ ...by, reason: 'final record matches source' }); return; }
|
|
1422
|
+
const back = { ...rec, claimed_root_cause: rec.root_cause };
|
|
1423
|
+
const next = decide(root, back, { ok: true, stdout: JSON.stringify({ verdict: parsed.verdict || 'confirmed', reason: parsed.reason, corrected: parsed.corrected, blockers: parsed.blockers, validation_plan: parsed.validation_plan }) }, by);
|
|
1424
|
+
if (next && next.verdict === 'confirmed') next.verified_by = [...rec.verified_by, by];
|
|
1425
|
+
if (next) records.splice(idx, 1, next);
|
|
1426
|
+
});
|
|
1427
|
+
for (const rec of confirmed.slice(checkNow.length)) {
|
|
1428
|
+
pending.push(rec.fingerprint);
|
|
1429
|
+
records.splice(records.indexOf(rec), 1);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Units held for a carried record: the verifier that re-checked it owns
|
|
1434
|
+
// them now. Without a verdict they wait for the next run.
|
|
1435
|
+
const verdictOf = new Map(records.map((r) => [r.fingerprint, r]));
|
|
1436
|
+
for (const u of ledger) {
|
|
1437
|
+
if (!u.reserved_for) continue;
|
|
1438
|
+
const fps = u.reserved_for;
|
|
1439
|
+
delete u.reserved_for;
|
|
1440
|
+
const checked = [];
|
|
1441
|
+
const missing = [];
|
|
1442
|
+
for (const fp of fps) {
|
|
1443
|
+
const rec = verdictOf.get(fp);
|
|
1444
|
+
const by = rec && ((rec.verified_by && rec.verified_by[0]) || rec.rejected_by);
|
|
1445
|
+
if (rec && by && validAgentId(by.agent_id)) checked.push({ fp, rec, by });
|
|
1446
|
+
else missing.push(fp);
|
|
1447
|
+
}
|
|
1448
|
+
if (!checked.length) { resetUnit(u, 'deferred', `carried record not re-checked this run: ${missing.join(', ')}`); continue; }
|
|
1449
|
+
const live = checked.filter((c) => c.rec.verdict !== 'rejected');
|
|
1450
|
+
if (!live.length && missing.length) { resetUnit(u, 'deferred', `carried record not re-checked this run: ${missing.join(', ')}`); continue; }
|
|
1451
|
+
const owner = (live[0] || checked[0]).by.agent_id;
|
|
1452
|
+
u.agent_id = owner;
|
|
1453
|
+
u.local_checks = checked.map(({ fp, rec, by }) => {
|
|
1454
|
+
const paths = [...new Set([...(rec.trace || []).map((t) => t.file), ...u.starting_paths])].filter(isRepoRel);
|
|
1455
|
+
return { agent_id: by.agent_id, reviewed_paths: paths, invariant: `re-check of carried record ${fp}`, method: 'source', result: `${rec.verdict}: ${str(by.reason || rec.reason || '', 600) || 'verdict recorded'}`, artifact: null, engine: by.engine };
|
|
1456
|
+
});
|
|
1457
|
+
u.reviewed_paths = [...new Set(u.local_checks.flatMap((c) => c.reviewed_paths))];
|
|
1458
|
+
if (live.length) { u.status = 'candidate'; u.result_fingerprints = live.map((c) => c.fp); u.unresolved = missing.map((fp) => `not re-checked this run: ${fp}`); }
|
|
1459
|
+
else { u.status = 'covered'; u.result_fingerprints = []; u.unresolved = []; }
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Ledger units whose candidate never got a verdict keep it as unresolved.
|
|
1463
|
+
for (const u of ledger) {
|
|
1464
|
+
if (u.status !== 'candidate') continue;
|
|
1465
|
+
const open = u.result_fingerprints.filter((fp) => pending.includes(fp));
|
|
1466
|
+
if (open.length) u.unresolved = [...new Set([...u.unresolved, ...open.map((fp) => `not validated this run: ${fp}`)])];
|
|
1467
|
+
}
|
|
1468
|
+
const byFp = new Map(queue.map((c) => [c.fingerprint, c]));
|
|
1469
|
+
for (const fp of pending) if (byFp.has(fp)) pendingRecords.push(stripCarried(byFp.get(fp)));
|
|
1470
|
+
records.sort((a, b) => a.fingerprint.localeCompare(b.fingerprint));
|
|
1471
|
+
// Each record is checked on its own, so an error cap can never let a bad
|
|
1472
|
+
// one through; then the whole set is checked once more.
|
|
1473
|
+
const bad = records.filter((r) => validateFindings([r], { root }).length);
|
|
1474
|
+
if (bad.length) {
|
|
1475
|
+
meta.validator_errors = bad.slice(0, 20).map((r) => `${r.fingerprint}: ${validateFindings([r], { root })[0]}`);
|
|
1476
|
+
const good = records.filter((r) => !bad.includes(r));
|
|
1477
|
+
const setErrs = validateFindings(good, { root });
|
|
1478
|
+
return finish('incomplete', 'findings_failed_validation', setErrs.length ? [] : good, pending);
|
|
1479
|
+
}
|
|
1480
|
+
meta.coverage_clean = coverageClean;
|
|
1481
|
+
meta.failures = failures.slice(0, 50);
|
|
1482
|
+
if (pending.length) return finish('incomplete', 'validation_budget_exhausted', records, pending);
|
|
1483
|
+
return finish('complete', null, records, pending);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function stripCarried(c) {
|
|
1487
|
+
const out = { ...c };
|
|
1488
|
+
delete out._carried;
|
|
1489
|
+
delete out.carried_from;
|
|
1490
|
+
return out;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function latestRun(root) {
|
|
1494
|
+
const outRoot = path.join(auditHome(), repoName(path.resolve(root)));
|
|
1495
|
+
const last = runDirs(outRoot).pop();
|
|
1496
|
+
if (!last) return null;
|
|
1497
|
+
return { dir: last.dir, meta: readJson(path.join(last.dir, 'run-metadata.json')), records: readJson(path.join(last.dir, 'findings.json')) || [] };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function shouldFailAudit(records) {
|
|
1501
|
+
return records.some((r) => r.verdict === 'confirmed' && r.severity && SEVERE.has(r.severity.overall));
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
module.exports = {
|
|
1505
|
+
PROFILES,
|
|
1506
|
+
DEFAULT_BUDGET,
|
|
1507
|
+
encodeRef,
|
|
1508
|
+
coverageId,
|
|
1509
|
+
mapTarget,
|
|
1510
|
+
mergePrior,
|
|
1511
|
+
validateLedger,
|
|
1512
|
+
validateFindings,
|
|
1513
|
+
extractJson,
|
|
1514
|
+
citationErrors,
|
|
1515
|
+
decide,
|
|
1516
|
+
runAudit,
|
|
1517
|
+
latestRun,
|
|
1518
|
+
shouldFailAudit,
|
|
1519
|
+
};
|