atris 3.82.5 → 3.82.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/atris.js +7 -2
- package/commands/security-review.js +18 -0
- package/commands/sentinel-bench.js +172 -0
- package/commands/sentinel-fix.js +243 -0
- package/commands/sentinel-powers.js +97 -11
- package/lib/security-audit.js +3 -0
- package/lib/sentinel-agents.js +75 -41
- package/lib/sentinel-bench.js +556 -0
- package/lib/sentinel-cloud.js +2 -1
- package/lib/sentinel-events.js +32 -10
- package/lib/sentinel-fix.js +777 -0
- package/lib/sentinel-guard-install.js +10 -4
- package/lib/sentinel-guard.js +142 -83
- package/lib/sentinel-improve.js +827 -0
- package/lib/sentinel-rules.js +108 -0
- package/lib/sentinel-rules.json +237 -0
- package/lib/sentinel-watch.js +940 -98
- package/package.json +1 -1
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Sentinel fix: turn a confirmed audit finding into a reviewed fix on its
|
|
4
|
+
// own branch.
|
|
5
|
+
//
|
|
6
|
+
// isolate needs a clean working tree. Makes sentinel/fix-<short-id> from
|
|
7
|
+
// the current branch inside a temp git worktree made by this run.
|
|
8
|
+
// Your checkout is never checked out, reset, stashed or cleaned.
|
|
9
|
+
// Never merges. Pushes only with --pr, from your checkout.
|
|
10
|
+
// build a builder engine (local codex or claude, or an atris model after
|
|
11
|
+
// a price is shown and accepted) writes the smallest fix plus a
|
|
12
|
+
// regression test in the temp worktree.
|
|
13
|
+
// prove three checks, all required before a commit:
|
|
14
|
+
// 1. the suite passes on the original code and the new test
|
|
15
|
+
// alone fails there on a real failed check (not a missing
|
|
16
|
+
// module, a syntax error or a timeout)
|
|
17
|
+
// 2. the new test and the full suite pass with the fix
|
|
18
|
+
// 3. a different engine (the checker) reads the fixed source and
|
|
19
|
+
// says the attack no longer works
|
|
20
|
+
// land commits in the temp worktree, only while it is on the fix branch
|
|
21
|
+
// and your branch has not moved. On any failure nothing is
|
|
22
|
+
// committed. The attempt is saved as a patch, the temp worktree is
|
|
23
|
+
// removed and the fix branch is deleted only while it still points
|
|
24
|
+
// at the start or at the commit this run made.
|
|
25
|
+
//
|
|
26
|
+
// Tests run with an allow-listed environment (PATH, locale, a temp HOME and
|
|
27
|
+
// TMPDIR, CI=1, NODE_ENV=test and nothing else from your shell), with
|
|
28
|
+
// GIT_TERMINAL_PROMPT=0, no system or global git config and push pointed at a
|
|
29
|
+
// dead url in that worktree's own config.
|
|
30
|
+
//
|
|
31
|
+
// Accepted limit: the generated tests still run as your own user process.
|
|
32
|
+
// They are not a sandbox. A test can still read or write any file your user
|
|
33
|
+
// can, open network connections, undo the worktree config, or push to a plain
|
|
34
|
+
// folder path through a url it builds itself. The scrubbing removes the
|
|
35
|
+
// credentials and the easy paths. Read the new test before you run this on a
|
|
36
|
+
// repo you do not trust.
|
|
37
|
+
//
|
|
38
|
+
// Zero external deps (Node built-ins only).
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const os = require('os');
|
|
42
|
+
const path = require('path');
|
|
43
|
+
const crypto = require('crypto');
|
|
44
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
45
|
+
|
|
46
|
+
const BRANCH_PREFIX = 'sentinel/fix-';
|
|
47
|
+
const LOCAL_BUILDERS = ['codex', 'claude'];
|
|
48
|
+
const DEFAULT_BUILD_TIMEOUT_MS = 20 * 60 * 1000;
|
|
49
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
50
|
+
const DEFAULT_TEST_TIMEOUT_MS = 20 * 60 * 1000;
|
|
51
|
+
const HOSTED_BUILDER = 'atris:fast';
|
|
52
|
+
const HOSTED_CHECKER = 'atris:pro';
|
|
53
|
+
const HOSTED_BUILD_SOURCE_BYTES = 64 * 1024;
|
|
54
|
+
const HOSTED_CHECK_MAX_BYTES = 64 * 1024;
|
|
55
|
+
const HOSTED_OUTPUT_TOKENS = { builder: 8192, verifier: 4096 };
|
|
56
|
+
const MAX_FILE_LIST = 400;
|
|
57
|
+
const PY_TEST = /(?:^|\/)test_[^/]*\.py$/;
|
|
58
|
+
const CO_AUTHOR = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
|
|
59
|
+
|
|
60
|
+
function audit() {
|
|
61
|
+
return require('./security-audit');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function shortId(fingerprint) {
|
|
65
|
+
return crypto.createHash('sha256').update(String(fingerprint)).digest('hex').slice(0, 8);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function branchFor(record) {
|
|
69
|
+
return `${BRANCH_PREFIX}${shortId(record.fingerprint)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isHosted(engine) {
|
|
73
|
+
return /^atris:[a-z0-9._-]+$/i.test(String(engine || ''));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------- git
|
|
77
|
+
|
|
78
|
+
function git(root, args, { allowFail = false } = {}) {
|
|
79
|
+
try {
|
|
80
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).replace(/\n$/, '');
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (allowFail) return null;
|
|
83
|
+
const msg = String((e.stderr && e.stderr.toString()) || e.message || e).trim().split('\n')[0];
|
|
84
|
+
const err = new Error(msg || `git ${args[0]} failed`);
|
|
85
|
+
err.gitFailed = true;
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function branchExists(root, branch) {
|
|
91
|
+
return git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { allowFail: true }) !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Every changed path in the working tree, from porcelain v1 -z.
|
|
95
|
+
function changedPaths(root) {
|
|
96
|
+
const out = git(root, ['status', '--porcelain', '-z', '--untracked-files=all']);
|
|
97
|
+
const parts = out.split('\0').filter(Boolean);
|
|
98
|
+
const list = [];
|
|
99
|
+
for (let i = 0; i < parts.length; i++) {
|
|
100
|
+
const entry = parts[i];
|
|
101
|
+
const code = entry.slice(0, 2);
|
|
102
|
+
const rel = entry.slice(3);
|
|
103
|
+
list.push({ code, path: rel });
|
|
104
|
+
if (code[0] === 'R' || code[0] === 'C') { list.push({ code: 'D ', path: parts[i + 1] }); i += 1; }
|
|
105
|
+
}
|
|
106
|
+
return list;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function headContent(root, rel) {
|
|
110
|
+
try {
|
|
111
|
+
return execFileSync('git', ['show', `HEAD:${rel}`], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
112
|
+
} catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function snapshot(root, files) {
|
|
116
|
+
const snap = new Map();
|
|
117
|
+
for (const rel of files) {
|
|
118
|
+
const full = path.join(root, rel);
|
|
119
|
+
snap.set(rel, fs.existsSync(full) ? fs.readFileSync(full) : null);
|
|
120
|
+
}
|
|
121
|
+
return snap;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function applySnapshot(root, snap) {
|
|
125
|
+
for (const [rel, buf] of snap) {
|
|
126
|
+
const full = path.join(root, rel);
|
|
127
|
+
if (buf === null) { fs.rmSync(full, { force: true }); continue; }
|
|
128
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
129
|
+
fs.writeFileSync(full, buf);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The tree was clean when the run began. Anything changed outside `keep`
|
|
134
|
+
// came from a test run. Put it back.
|
|
135
|
+
function dropArtifacts(root, keep) {
|
|
136
|
+
for (const c of changedPaths(root)) {
|
|
137
|
+
if (keep.has(c.path)) continue;
|
|
138
|
+
if (c.code === '??') fs.rmSync(path.join(root, c.path), { force: true, recursive: true });
|
|
139
|
+
else git(root, ['checkout', 'HEAD', '--', c.path], { allowFail: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isTestFile(rel) {
|
|
144
|
+
return audit().TEST_PATH.test(rel) || PY_TEST.test(rel);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------- tests
|
|
148
|
+
|
|
149
|
+
function detectTestCommand(root) {
|
|
150
|
+
const has = (rel) => fs.existsSync(path.join(root, rel));
|
|
151
|
+
if (has('package.json')) {
|
|
152
|
+
try {
|
|
153
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
154
|
+
const t = pkg && pkg.scripts && pkg.scripts.test;
|
|
155
|
+
if (typeof t === 'string' && t.trim() && !/no test specified/i.test(t)) return 'npm test';
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
if (has('go.mod')) return 'go test ./...';
|
|
159
|
+
if (['pytest.ini', 'conftest.py', 'tox.ini', 'pyproject.toml', 'setup.py', 'setup.cfg'].some(has)) return 'pytest';
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function npmTestScript(buf) {
|
|
164
|
+
try { const pkg = JSON.parse(String(buf)); return String((pkg.scripts && pkg.scripts.test) || ''); } catch { return ''; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function runTestCommand(cmd, { cwd, env = process.env, timeoutMs = DEFAULT_TEST_TIMEOUT_MS } = {}) {
|
|
168
|
+
const childEnv = { ...env };
|
|
169
|
+
// A test runner started from inside node --test would report to it.
|
|
170
|
+
delete childEnv.NODE_TEST_CONTEXT;
|
|
171
|
+
const r = spawnSync(cmd, { cwd, env: childEnv, shell: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 });
|
|
172
|
+
const output = `${r.stdout || ''}${r.stderr || ''}`;
|
|
173
|
+
return { code: r.status === null ? -1 : r.status, timedOut: Boolean(r.error && r.error.code === 'ETIMEDOUT'), output: output.slice(-256 * 1024) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- findings
|
|
177
|
+
|
|
178
|
+
function confirmedFindings(root) {
|
|
179
|
+
const last = audit().latestRun(root);
|
|
180
|
+
if (!last) return { dir: null, records: [] };
|
|
181
|
+
return { dir: last.dir, records: (last.records || []).filter((r) => r && r.verdict === 'confirmed') };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function matchFinding(records, id) {
|
|
185
|
+
const want = String(id || '').trim();
|
|
186
|
+
if (!want) return null;
|
|
187
|
+
return records.find((r) => r.fingerprint === want || shortId(r.fingerprint) === want || branchFor(r) === want) || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function findingView(r) {
|
|
191
|
+
return {
|
|
192
|
+
title: r.title,
|
|
193
|
+
attack_class: r.attack_class,
|
|
194
|
+
description: r.description,
|
|
195
|
+
root_cause: r.root_cause,
|
|
196
|
+
attacker: r.attacker,
|
|
197
|
+
impact: r.impact,
|
|
198
|
+
intended_behavior: r.intended_behavior,
|
|
199
|
+
trace: (r.trace || []).map((t) => ({ kind: t.kind, file: t.file, line: t.line, description: t.description, code: t.code })),
|
|
200
|
+
conditions: r.conditions || [],
|
|
201
|
+
suggested_fix: r.remediation ? r.remediation.strategy : '',
|
|
202
|
+
regression_test_idea: r.remediation ? r.remediation.regression_test : '',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function citedFiles(record) {
|
|
207
|
+
const isRel = audit().isRepoRel;
|
|
208
|
+
return [...new Set((record.trace || []).map((t) => t && t.file).filter(isRel))];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------- prompts
|
|
212
|
+
|
|
213
|
+
function builderPrompt(record, { testCommand, hosted = false, source = '', fileList = [] }) {
|
|
214
|
+
const out = [
|
|
215
|
+
'You are the builder on the Atris sentinel team. A security audit confirmed the problem below in this repository and a different model checked it. Fix it.',
|
|
216
|
+
'',
|
|
217
|
+
'## What to do',
|
|
218
|
+
'- Make the smallest change that stops the attack. Follow the suggested fix unless the source shows a smaller correct one.',
|
|
219
|
+
'- Add a regression test in the project\'s own test style that fails on the current code and passes after your fix. It must exercise the attack itself, not only the helper you change.',
|
|
220
|
+
`- The test command is: ${testCommand}. The whole suite must pass after your change.`,
|
|
221
|
+
'- Do not touch unrelated files. Do not delete, skip or weaken existing tests.',
|
|
222
|
+
'- Do not commit, switch branches, push, or change git settings. Do not write outside this repository. Do not use the network.',
|
|
223
|
+
];
|
|
224
|
+
if (hosted) {
|
|
225
|
+
out.push(
|
|
226
|
+
'- You cannot open files in this session. The cited source is below. Put the regression test in a new file next to the project\'s other tests.',
|
|
227
|
+
'',
|
|
228
|
+
'## Answer',
|
|
229
|
+
'Return only one JSON object: {"files":[{"path":"repo/relative/path","content":"the full new file content"}],"summary":"one plain sentence describing the change"}. List every file you change or add, each with its complete content.',
|
|
230
|
+
);
|
|
231
|
+
} else {
|
|
232
|
+
out.push('', 'When you are done, end with one line of JSON: {"summary":"one plain sentence describing the change","test_files":["paths of the tests you added or changed"]}');
|
|
233
|
+
}
|
|
234
|
+
out.push('', '## Confirmed finding', JSON.stringify(findingView(record), null, 2));
|
|
235
|
+
if (hosted && fileList.length) out.push('', '## Files in the repository', fileList.join('\n'));
|
|
236
|
+
if (source) out.push('', source);
|
|
237
|
+
return out.join('\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function checkerPrompt(record, { diff, hosted = false, source = '' }) {
|
|
241
|
+
return [
|
|
242
|
+
'You are an independent checker on the Atris sentinel team. A security audit confirmed the problem below. A different model wrote the change shown after it. Decide from the source whether the attack still works.',
|
|
243
|
+
'',
|
|
244
|
+
'## How to check',
|
|
245
|
+
hosted ? '- Read the change and the source below.' : '- Open the cited files and every changed file yourself. Read the code as it is now in this folder.',
|
|
246
|
+
'- Walk the attack from its entry point to the sink. Look for any path around the fix: other callers, other inputs, encodings, a second sink.',
|
|
247
|
+
'- Check that the new test really drives the attack.',
|
|
248
|
+
'- Read-only: do not change files, run the target, or use the network.',
|
|
249
|
+
'',
|
|
250
|
+
'## Confirmed finding',
|
|
251
|
+
JSON.stringify(findingView(record), null, 2),
|
|
252
|
+
'',
|
|
253
|
+
'## The change',
|
|
254
|
+
'```diff',
|
|
255
|
+
diff,
|
|
256
|
+
'```',
|
|
257
|
+
source ? `\n${source}` : '',
|
|
258
|
+
'',
|
|
259
|
+
'## Answer',
|
|
260
|
+
'Return only one JSON object: {"verdict":"fixed or not_fixed","reason":"one sentence on what you read"}',
|
|
261
|
+
].join('\n');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function hostedSourceBlock(root, files, allowed, maxBytes) {
|
|
265
|
+
const blocks = [];
|
|
266
|
+
let used = 0;
|
|
267
|
+
for (const rel of files) {
|
|
268
|
+
if (!allowed.has(rel)) continue;
|
|
269
|
+
let text;
|
|
270
|
+
try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { continue; }
|
|
271
|
+
const block = `### ${rel}\n\`\`\`\n${text}\n\`\`\``;
|
|
272
|
+
if (used + block.length > maxBytes) continue;
|
|
273
|
+
used += block.length;
|
|
274
|
+
blocks.push(block);
|
|
275
|
+
}
|
|
276
|
+
return blocks.length ? ['## Source', ...blocks].join('\n\n') : '';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------- engines
|
|
280
|
+
|
|
281
|
+
function builderInvocation(engine, prompt, { model = '', effort = '' } = {}) {
|
|
282
|
+
const { RUNNER_PROFILE_DEFS, applySpendGate, DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
|
|
283
|
+
const profile = RUNNER_PROFILE_DEFS[engine];
|
|
284
|
+
const effortArgs = (() => {
|
|
285
|
+
const word = String(effort || '').trim().toLowerCase();
|
|
286
|
+
if (!word || !profile || !profile.effortFlag || !(profile.efforts || []).includes(word)) return [];
|
|
287
|
+
const parts = profile.effortFlag.split(/\s+/).filter(Boolean);
|
|
288
|
+
const last = parts.pop();
|
|
289
|
+
return last.endsWith('=') ? [...parts, `${last}${word}`] : [...parts, last, word];
|
|
290
|
+
})();
|
|
291
|
+
if (engine === 'codex') {
|
|
292
|
+
// workspace-write: codex may edit only the folder it runs in.
|
|
293
|
+
return { engine, bin: 'codex', args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'workspace-write', '--ephemeral', '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '--color', 'never', prompt] };
|
|
294
|
+
}
|
|
295
|
+
if (engine === 'claude') {
|
|
296
|
+
// File tools only, no shell. acceptEdits allows edits inside the folder it runs in.
|
|
297
|
+
return { engine, bin: 'claude', args: ['-p', prompt, '--model', model || applySpendGate(DEFAULT_CLAUDE_RUNNER_MODEL), ...effortArgs, '--tools', 'Read,Glob,Grep,Edit,Write', '--permission-mode', 'acceptEdits', '--no-session-persistence'] };
|
|
298
|
+
}
|
|
299
|
+
throw new Error(`${engine} cannot build fixes. use codex or claude`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function rosterPin(engine, root) {
|
|
303
|
+
try { return require('./engine-ask').askRosterPin(engine, root) || {}; } catch { return {}; }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function defaultRunBuilder({ engine, prompt, root, env, timeoutMs }) {
|
|
307
|
+
const { runAskProcess } = require('./engine-ask');
|
|
308
|
+
const pin = rosterPin(engine, root);
|
|
309
|
+
const inv = builderInvocation(engine, prompt, { model: pin.roster_model || '', effort: pin.roster_effort || '' });
|
|
310
|
+
return runAskProcess(inv, { cwd: root, timeoutMs, env });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function defaultRunChecker({ engine, prompt, root, env, timeoutMs }) {
|
|
314
|
+
const { runAskProcess, buildReadOnlyEngineInvocation } = require('./engine-ask');
|
|
315
|
+
const pin = rosterPin(engine, root);
|
|
316
|
+
const inv = buildReadOnlyEngineInvocation(engine, prompt, pin.roster_model || '', { timeoutMs, effort: pin.roster_effort || '' });
|
|
317
|
+
return runAskProcess(inv, { cwd: inv.cwd || root, timeoutMs, env });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Pick the builder and checker. They must differ.
|
|
321
|
+
// Only these engines may build or check: local codex, local claude, or an
|
|
322
|
+
// atris model (which always goes through the price and a yes).
|
|
323
|
+
function allowedEngine(name) {
|
|
324
|
+
return LOCAL_BUILDERS.includes(name) || isHosted(name);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Pick the builder and checker. They must differ.
|
|
328
|
+
function resolveFixEngines({ engine = null, builder = null, verifier = null, env = process.env, available = null } = {}) {
|
|
329
|
+
const cloud = require('./sentinel-cloud');
|
|
330
|
+
const has = available || ((name) => cloud.localEngineAvailable(name, { env }));
|
|
331
|
+
if (engine && engine !== 'atris' && engine !== 'local') throw new Error('--engine must be atris or local');
|
|
332
|
+
for (const [flag, name] of [['--builder', builder], ['--verifier', verifier]]) {
|
|
333
|
+
if (name && !allowedEngine(name)) throw new Error(`${flag} ${name} is not allowed. use codex, claude or an atris model like atris:pro`);
|
|
334
|
+
}
|
|
335
|
+
if (engine === 'atris' && (builder || verifier)) throw new Error('pick --engine atris or --builder and --verifier, not both');
|
|
336
|
+
if (engine === 'atris') return { builder: HOSTED_BUILDER, verifier: HOSTED_CHECKER };
|
|
337
|
+
if (engine === 'local' && ((builder && isHosted(builder)) || (verifier && isHosted(verifier)))) throw new Error('--engine local runs only codex and claude on this machine');
|
|
338
|
+
let b = builder;
|
|
339
|
+
if (!b) b = LOCAL_BUILDERS.find((n) => has(n)) || null;
|
|
340
|
+
if (!b) throw new Error('no codex or claude found on this machine. install one or add --engine atris');
|
|
341
|
+
let v = verifier;
|
|
342
|
+
if (!v) v = isHosted(b) ? HOSTED_CHECKER : (LOCAL_BUILDERS.find((n) => n !== b && has(n)) || null);
|
|
343
|
+
if (!v) throw new Error(`the check needs a second engine besides ${b}. install ${b === 'codex' ? 'claude' : 'codex'} or name one with --verifier`);
|
|
344
|
+
if (v === b) throw new Error('the checker must be a different engine than the builder');
|
|
345
|
+
return { builder: b, verifier: v };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function lastJson(text, accept) {
|
|
349
|
+
return audit().extractJson(text, accept);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Paths a hosted builder may write: inside the repo, not .git, not a key or
|
|
353
|
+
// .env-style file. An existing file must be one it was allowed to see.
|
|
354
|
+
function hostedWritable(root, rel, allowed) {
|
|
355
|
+
const a = audit();
|
|
356
|
+
if (!a.isRepoRel(rel)) return false;
|
|
357
|
+
const norm = path.posix.normalize(rel);
|
|
358
|
+
if (norm !== rel || rel.split('/').includes('.git')) return false;
|
|
359
|
+
if (a.sensitiveFile(rel)) return false;
|
|
360
|
+
const full = path.join(root, rel);
|
|
361
|
+
let rootReal = root;
|
|
362
|
+
try { rootReal = fs.realpathSync(root); } catch {}
|
|
363
|
+
let cur = path.dirname(full);
|
|
364
|
+
while (!fs.existsSync(cur)) cur = path.dirname(cur);
|
|
365
|
+
const real = fs.realpathSync(cur);
|
|
366
|
+
const relReal = path.relative(rootReal, real);
|
|
367
|
+
if (relReal.startsWith('..') || path.isAbsolute(relReal)) return false;
|
|
368
|
+
if (fs.existsSync(full)) {
|
|
369
|
+
if (fs.lstatSync(full).isSymbolicLink()) return false;
|
|
370
|
+
if (!allowed.has(rel)) return false;
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------- isolation
|
|
376
|
+
|
|
377
|
+
// Environment for generated tests: an allow-list. Nothing from your shell
|
|
378
|
+
// passes except PATH, locale, terminal type, an active Python virtual
|
|
379
|
+
// environment path and on Windows the few names a process needs to start.
|
|
380
|
+
// Everything else (tokens, keys, DATABASE_URL and any other url that may hold
|
|
381
|
+
// a password) stays out. HOME, TMPDIR and the Go folders point inside the
|
|
382
|
+
// temp copy, git cannot prompt and reads no system or global config.
|
|
383
|
+
const PASS_EXACT = ['PATH', 'TERM'];
|
|
384
|
+
const PASS_WINDOWS = ['PATHEXT', 'SystemRoot', 'SYSTEMROOT', 'COMSPEC', 'ComSpec', 'WINDIR', 'windir'];
|
|
385
|
+
const PASS_LOCALE = /^(?:LANG|LANGUAGE|LC_[A-Z_]+)$/;
|
|
386
|
+
|
|
387
|
+
function scrubbedEnv(env, base, { platform = process.platform } = {}) {
|
|
388
|
+
const src = env || {};
|
|
389
|
+
const out = {};
|
|
390
|
+
for (const k of PASS_EXACT) if (typeof src[k] === 'string') out[k] = src[k];
|
|
391
|
+
for (const k of Object.keys(src)) if (PASS_LOCALE.test(k)) out[k] = src[k];
|
|
392
|
+
if (platform === 'win32') for (const k of PASS_WINDOWS) if (typeof src[k] === 'string') out[k] = src[k];
|
|
393
|
+
// A virtual environment is a folder path. One that carries a url or a login is refused.
|
|
394
|
+
if (typeof src.VIRTUAL_ENV === 'string' && path.isAbsolute(src.VIRTUAL_ENV) && !/[@:]\/\/|@/.test(src.VIRTUAL_ENV)) out.VIRTUAL_ENV = src.VIRTUAL_ENV;
|
|
395
|
+
const home = path.join(base, 'home');
|
|
396
|
+
const tmp = path.join(base, 'tmp');
|
|
397
|
+
for (const dir of [home, tmp]) fs.mkdirSync(dir, { recursive: true });
|
|
398
|
+
Object.assign(out, {
|
|
399
|
+
HOME: home,
|
|
400
|
+
USERPROFILE: home,
|
|
401
|
+
TMPDIR: tmp,
|
|
402
|
+
TMP: tmp,
|
|
403
|
+
TEMP: tmp,
|
|
404
|
+
CI: '1',
|
|
405
|
+
NODE_ENV: 'test',
|
|
406
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
407
|
+
GOPATH: path.join(base, 'go'),
|
|
408
|
+
GOCACHE: path.join(base, 'go-cache'),
|
|
409
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
410
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
411
|
+
GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'),
|
|
412
|
+
});
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const NO_PUSH_URL = 'atris-sentinel-push-disabled://blocked';
|
|
417
|
+
const PUSH_PREFIXES = ['https://', 'http://', 'ssh://', 'git://', 'git@', 'file://', '/', './', '../'];
|
|
418
|
+
|
|
419
|
+
// Pushing from the temp worktree goes nowhere: every remote gets a dead
|
|
420
|
+
// pushurl and every url form is rewritten to a dead one, in that worktree's
|
|
421
|
+
// own config only.
|
|
422
|
+
function disablePush(root, wt) {
|
|
423
|
+
const had = git(root, ['config', '--get', 'extensions.worktreeConfig'], { allowFail: true });
|
|
424
|
+
let enabled = false;
|
|
425
|
+
if (String(had).trim() !== 'true') { git(root, ['config', 'extensions.worktreeConfig', 'true']); enabled = true; }
|
|
426
|
+
const remotes = (git(wt, ['remote'], { allowFail: true }) || '').split('\n').filter(Boolean);
|
|
427
|
+
for (const r of remotes) git(wt, ['config', '--worktree', `remote.${r}.pushurl`, NO_PUSH_URL]);
|
|
428
|
+
for (const p of PUSH_PREFIXES) git(wt, ['config', '--worktree', '--add', `url.${NO_PUSH_URL}.pushInsteadOf`, p]);
|
|
429
|
+
git(wt, ['config', '--worktree', 'push.default', 'nothing']);
|
|
430
|
+
return { enabled };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Turn the worktree config switch back off when this run turned it on and no
|
|
434
|
+
// other linked worktree is left.
|
|
435
|
+
function restoreWorktreeConfig(root, state) {
|
|
436
|
+
if (!state || !state.enabled) return;
|
|
437
|
+
const count = (git(root, ['worktree', 'list', '--porcelain'], { allowFail: true }) || '').split('\n').filter((l) => l.startsWith('worktree ')).length;
|
|
438
|
+
if (count <= 1) git(root, ['config', '--unset', 'extensions.worktreeConfig'], { allowFail: true });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------- proof
|
|
442
|
+
|
|
443
|
+
// The command that runs only the given test files, by their language.
|
|
444
|
+
// null when sentinel does not know a runner for them.
|
|
445
|
+
function singleTestCommand(files, wt) {
|
|
446
|
+
const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
447
|
+
if (files.every((f) => /\.(?:c|m)?js$/.test(f))) return { runner: 'node', cmd: `node --test ${files.map(q).join(' ')}` };
|
|
448
|
+
if (files.every((f) => /\.py$/.test(f))) return { runner: 'pytest', cmd: `python3 -m pytest -q -p no:cacheprovider ${files.map(q).join(' ')}` };
|
|
449
|
+
if (files.every((f) => /_test\.go$/.test(f))) {
|
|
450
|
+
const names = [];
|
|
451
|
+
for (const f of files) {
|
|
452
|
+
let text = '';
|
|
453
|
+
try { text = fs.readFileSync(path.join(wt, f), 'utf8'); } catch {}
|
|
454
|
+
for (const m of text.matchAll(/^func\s+(Test\w+)\s*\(\s*\w+\s+\*testing\.T\s*\)/gm)) names.push(m[1]);
|
|
455
|
+
}
|
|
456
|
+
if (!names.length) return null;
|
|
457
|
+
const dirs = [...new Set(files.map((f) => `./${path.posix.dirname(f)}`))];
|
|
458
|
+
return { runner: 'go', cmd: `go test -count=1 ${dirs.map(q).join(' ')} -run ${q(`^(${names.join('|')})$`)}` };
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Output that means the test broke for a reason other than a failed check.
|
|
464
|
+
const NOT_A_CHECK = {
|
|
465
|
+
node: /Cannot find module|MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|ERR_REQUIRE|SyntaxError|ReferenceError|TypeError|is not a function|is not defined|is not a constructor|cannot read propert|timed out|testTimeoutFailure|hookFailed|cancelledByParent/i,
|
|
466
|
+
pytest: /ImportError|ModuleNotFoundError|SyntaxError|IndentationError|NameError|AttributeError|TypeError|errors? during collection|Interrupted|\bERROR\b|Timeout|fixture '.*' not found/,
|
|
467
|
+
go: /\[build failed\]|\[setup failed\]|panic:|undefined:|cannot find package|no required module|syntax error|test timed out|no test files|imported and not used|declared and not used/,
|
|
468
|
+
};
|
|
469
|
+
// Output that shows a check in the test failed.
|
|
470
|
+
const REAL_FAILURE = {
|
|
471
|
+
node: /AssertionError|ERR_ASSERTION/,
|
|
472
|
+
pytest: /^E\s+(?:assert\b|AssertionError)/m,
|
|
473
|
+
go: /^\s*--- FAIL: /m,
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// A real regression proof: the run failed because a check in the test
|
|
477
|
+
// failed, not because something was missing, broken or slow. Anything
|
|
478
|
+
// unclear counts as no proof.
|
|
479
|
+
function realFailure(runner, run) {
|
|
480
|
+
if (!NOT_A_CHECK[runner] || run.timedOut || run.code === 0 || run.code === -1) return false;
|
|
481
|
+
if (runner === 'pytest' && run.code !== 1) return false;
|
|
482
|
+
if (NOT_A_CHECK[runner].test(run.output)) return false;
|
|
483
|
+
return REAL_FAILURE[runner].test(run.output);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---------------------------------------------------------------- one fix
|
|
487
|
+
|
|
488
|
+
function patchDir(env, repo, short) {
|
|
489
|
+
const home = env.HOME || os.homedir();
|
|
490
|
+
return path.join(home, '.atris', 'sentinel', 'fixes', `${repo}-${short}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function saveAttempt(wt, env, repo, short) {
|
|
494
|
+
try {
|
|
495
|
+
if (!fs.existsSync(wt) || !changedPaths(wt).length) return null;
|
|
496
|
+
git(wt, ['add', '-A']);
|
|
497
|
+
const patch = execFileSync('git', ['diff', '--cached', '--binary', 'HEAD'], { cwd: wt, maxBuffer: 64 * 1024 * 1024 });
|
|
498
|
+
if (!patch.length) return null;
|
|
499
|
+
const dir = patchDir(env, repo, short);
|
|
500
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
501
|
+
const file = path.join(dir, 'attempt.patch');
|
|
502
|
+
fs.writeFileSync(file, patch, { mode: 0o600 });
|
|
503
|
+
return file;
|
|
504
|
+
} catch { return null; }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function diffText(root, files) {
|
|
508
|
+
const parts = [];
|
|
509
|
+
for (const rel of files) {
|
|
510
|
+
const tracked = headContent(root, rel) !== null;
|
|
511
|
+
const args = tracked ? ['diff', 'HEAD', '--', rel] : ['diff', '--no-index', '--', '/dev/null', rel];
|
|
512
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
513
|
+
parts.push(String(r.stdout || ''));
|
|
514
|
+
}
|
|
515
|
+
return parts.join('\n').trim();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function oneLine(text, max = 300) {
|
|
519
|
+
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function commitMessage(record, verifier) {
|
|
523
|
+
const title = oneLine(record.title, 200).replace(/[.\s]+$/, '');
|
|
524
|
+
const subject = `Fix: ${title}`.slice(0, 100);
|
|
525
|
+
return [
|
|
526
|
+
subject,
|
|
527
|
+
'',
|
|
528
|
+
`Sentinel finding ${record.fingerprint}.`,
|
|
529
|
+
`The new regression test fails on the old code and passes with this change. ${verifier} checked from source that the attack no longer works.`,
|
|
530
|
+
'Made by atris sentinel fix. Review before merging.',
|
|
531
|
+
'',
|
|
532
|
+
CO_AUTHOR,
|
|
533
|
+
].join('\n');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function refSha(root, ref) {
|
|
537
|
+
return git(root, ['rev-parse', '--verify', '--quiet', ref], { allowFail: true });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// The whole fix runs in a temp worktree on the fix branch. The user's
|
|
541
|
+
// checkout is never checked out, reset, stashed or cleaned.
|
|
542
|
+
async function fixOne(root, record, ctx) {
|
|
543
|
+
const { from, testCommand, engines, env, runBuilder, runChecker, hostedRun, allowed, timeouts, log } = ctx;
|
|
544
|
+
const short = shortId(record.fingerprint);
|
|
545
|
+
const branch = branchFor(record);
|
|
546
|
+
const ref = `refs/heads/${branch}`;
|
|
547
|
+
const repo = audit().repoName(root);
|
|
548
|
+
const result = { fingerprint: record.fingerprint, short_id: short, title: record.title, branch, from, ok: false, committed: false, proofs: {} };
|
|
549
|
+
if (branchExists(root, branch)) {
|
|
550
|
+
result.reason = `a fix branch for this finding already exists: ${branch}`;
|
|
551
|
+
result.branch = null;
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
const start = refSha(root, `refs/heads/${from}`);
|
|
555
|
+
const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), `atris-sentinel-fix-${short}-`));
|
|
556
|
+
const wt = path.join(tmpBase, 'repo');
|
|
557
|
+
let made = null;
|
|
558
|
+
let pushState = null;
|
|
559
|
+
const testEnv = scrubbedEnv(env, path.join(tmpBase, 'env'));
|
|
560
|
+
const runTests = (cmd) => runTestCommand(cmd, { cwd: wt, env: testEnv, timeoutMs: timeouts.test });
|
|
561
|
+
const removeWorktree = () => {
|
|
562
|
+
if (fs.existsSync(wt)) git(root, ['worktree', 'remove', '--force', '--force', wt], { allowFail: true });
|
|
563
|
+
fs.rmSync(tmpBase, { recursive: true, force: true });
|
|
564
|
+
git(root, ['worktree', 'prune'], { allowFail: true });
|
|
565
|
+
try { restoreWorktreeConfig(root, pushState); } catch {}
|
|
566
|
+
};
|
|
567
|
+
const fail = (reason) => {
|
|
568
|
+
result.reason = reason;
|
|
569
|
+
result.patch = saveAttempt(wt, env, repo, short);
|
|
570
|
+
removeWorktree();
|
|
571
|
+
// Delete the fix branch only while it still points at a commit this run made or at the start.
|
|
572
|
+
const tip = refSha(root, ref);
|
|
573
|
+
if (tip && (tip === start || (made && tip === made))) {
|
|
574
|
+
git(root, ['update-ref', '-d', ref, tip], { allowFail: true });
|
|
575
|
+
result.branch = null;
|
|
576
|
+
} else if (tip) {
|
|
577
|
+
result.reason = `${reason}. the branch ${branch} now points at a commit this run did not make and was left alone`;
|
|
578
|
+
} else {
|
|
579
|
+
result.branch = null;
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
};
|
|
583
|
+
try {
|
|
584
|
+
git(root, ['worktree', 'add', '-q', '-b', branch, wt, start]);
|
|
585
|
+
pushState = disablePush(root, wt);
|
|
586
|
+
const branchOk = () => git(wt, ['symbolic-ref', '-q', 'HEAD'], { allowFail: true }) === ref && refSha(root, ref) === start;
|
|
587
|
+
|
|
588
|
+
log(`running the tests on the original code: ${testCommand}`);
|
|
589
|
+
const base = runTests(testCommand);
|
|
590
|
+
if (base.code !== 0) return fail('the tests already fail on the original code before any change');
|
|
591
|
+
dropArtifacts(wt, new Set());
|
|
592
|
+
|
|
593
|
+
log(`asking ${engines.builder} for the smallest fix and a regression test`);
|
|
594
|
+
let summary = '';
|
|
595
|
+
let declared = [];
|
|
596
|
+
if (isHosted(engines.builder)) {
|
|
597
|
+
const fileList = [...allowed].slice(0, MAX_FILE_LIST);
|
|
598
|
+
const prompt = builderPrompt(record, { testCommand, hosted: true, fileList, source: hostedSourceBlock(wt, citedFiles(record), allowed, HOSTED_BUILD_SOURCE_BYTES) });
|
|
599
|
+
const [answer] = await hostedRun([{ engine: engines.builder, prompt, max_output_tokens: HOSTED_OUTPUT_TOKENS.builder }]);
|
|
600
|
+
if (!answer || answer.ok === false) return fail(`the builder did not answer: ${oneLine(answer && answer.reason, 200) || 'no answer'}`);
|
|
601
|
+
const parsed = lastJson(answer.stdout, (v) => Array.isArray(v.files));
|
|
602
|
+
if (!parsed || !parsed.files.length) return fail('the builder answer had no files to change');
|
|
603
|
+
for (const f of parsed.files) {
|
|
604
|
+
if (!f || typeof f.path !== 'string' || typeof f.content !== 'string' || !hostedWritable(wt, f.path, allowed)) return fail(`the builder tried to write a file it may not touch: ${oneLine(f && f.path, 120) || 'unnamed'}`);
|
|
605
|
+
}
|
|
606
|
+
for (const f of parsed.files) {
|
|
607
|
+
const full = path.join(wt, f.path);
|
|
608
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
609
|
+
fs.writeFileSync(full, f.content);
|
|
610
|
+
}
|
|
611
|
+
summary = oneLine(parsed.summary, 300);
|
|
612
|
+
} else {
|
|
613
|
+
const prompt = builderPrompt(record, { testCommand });
|
|
614
|
+
const answer = await runBuilder({ engine: engines.builder, prompt, root: wt, env, timeoutMs: timeouts.build });
|
|
615
|
+
if (!answer || answer.ok === false) return fail(`the builder stopped before it finished (${oneLine(answer && answer.reason, 80) || 'no answer'})`);
|
|
616
|
+
const parsed = lastJson(answer.stdout, (v) => typeof v.summary === 'string' || Array.isArray(v.test_files));
|
|
617
|
+
if (parsed) { summary = oneLine(parsed.summary, 300); declared = Array.isArray(parsed.test_files) ? parsed.test_files : []; }
|
|
618
|
+
}
|
|
619
|
+
if (!branchOk()) return fail('the builder committed or switched branches on its own');
|
|
620
|
+
const changes = changedPaths(wt);
|
|
621
|
+
if (!changes.length) return fail('the builder changed nothing');
|
|
622
|
+
const files = [...new Set(changes.map((c) => c.path))];
|
|
623
|
+
const declaredSet = new Set(declared.filter((p) => typeof p === 'string'));
|
|
624
|
+
const tests = files.filter((f) => isTestFile(f) || declaredSet.has(f));
|
|
625
|
+
const src = files.filter((f) => !tests.includes(f));
|
|
626
|
+
if (!tests.length) return fail('the builder added no regression test');
|
|
627
|
+
if (!src.length) return fail('the builder changed only tests and left the code as it was');
|
|
628
|
+
if (files.some((f) => audit().sensitiveFile(f))) return fail('the builder changed a key or secrets file that a fix may not touch');
|
|
629
|
+
if (tests.some((f) => headContent(wt, f) !== null && !fs.existsSync(path.join(wt, f)))) return fail('the builder deleted an existing test');
|
|
630
|
+
if (files.includes('package.json') && npmTestScript(headContent(wt, 'package.json')) !== npmTestScript(fs.readFileSync(path.join(wt, 'package.json')))) {
|
|
631
|
+
return fail('the builder changed the test command itself');
|
|
632
|
+
}
|
|
633
|
+
const single = singleTestCommand(tests, wt);
|
|
634
|
+
if (!single) return fail('sentinel cannot run the new test on its own (it knows node --test, pytest and go test)');
|
|
635
|
+
result.files = files;
|
|
636
|
+
result.test_files = tests;
|
|
637
|
+
result.summary = summary;
|
|
638
|
+
const fixed = snapshot(wt, files);
|
|
639
|
+
const keep = new Set(files);
|
|
640
|
+
const settle = () => { applySnapshot(wt, fixed); dropArtifacts(wt, keep); };
|
|
641
|
+
|
|
642
|
+
log('checking that the new test fails on the original code');
|
|
643
|
+
applySnapshot(wt, new Map(src.map((rel) => [rel, headContent(wt, rel)])));
|
|
644
|
+
const before = runTests(single.cmd);
|
|
645
|
+
settle();
|
|
646
|
+
if (!branchOk()) return fail('a test committed or switched branches while it ran');
|
|
647
|
+
if (before.code === 0) return fail('the new test also passes on the original code and does not catch this problem');
|
|
648
|
+
if (!realFailure(single.runner, before)) return fail('the new test fails on the original code only because something is missing, broken or slow and not because a check failed');
|
|
649
|
+
result.proofs.fails_before = true;
|
|
650
|
+
|
|
651
|
+
log('running the new test and the full test suite with the fix');
|
|
652
|
+
const alone = runTests(single.cmd);
|
|
653
|
+
settle();
|
|
654
|
+
if (alone.code !== 0) return fail('the new test fails with the fix in place');
|
|
655
|
+
const after = runTests(testCommand);
|
|
656
|
+
settle();
|
|
657
|
+
if (!branchOk()) return fail('a test committed or switched branches while it ran');
|
|
658
|
+
if (after.code !== 0) return fail(after.timedOut ? 'the test suite ran out of time with the fix in place' : 'the test suite fails with the fix in place');
|
|
659
|
+
result.proofs.passes_after = true;
|
|
660
|
+
|
|
661
|
+
log(`asking ${engines.verifier} to check from source that the attack no longer works`);
|
|
662
|
+
const diff = diffText(wt, files);
|
|
663
|
+
let answer;
|
|
664
|
+
if (isHosted(engines.verifier)) {
|
|
665
|
+
const prompt = checkerPrompt(record, { diff, hosted: true, source: hostedSourceBlock(wt, [...new Set([...citedFiles(record), ...files])], new Set([...allowed, ...files.filter((f) => !audit().sensitiveFile(f))]), HOSTED_CHECK_MAX_BYTES / 2) });
|
|
666
|
+
if (Buffer.byteLength(prompt) > HOSTED_CHECK_MAX_BYTES) return fail('the change is too large for the checker on atris models');
|
|
667
|
+
[answer] = await hostedRun([{ engine: engines.verifier, prompt, max_output_tokens: HOSTED_OUTPUT_TOKENS.verifier }]);
|
|
668
|
+
} else {
|
|
669
|
+
answer = await runChecker({ engine: engines.verifier, prompt: checkerPrompt(record, { diff }), root: wt, env, timeoutMs: timeouts.check });
|
|
670
|
+
}
|
|
671
|
+
settle();
|
|
672
|
+
const verdict = answer && answer.ok !== false ? lastJson(answer.stdout, (v) => typeof v.verdict === 'string') : null;
|
|
673
|
+
if (!verdict) return fail(`the checker gave no clear answer (${oneLine(answer && answer.reason, 80) || 'unreadable'})`);
|
|
674
|
+
result.checker_reason = oneLine(verdict.reason, 400);
|
|
675
|
+
if (verdict.verdict !== 'fixed') return fail(`${engines.verifier} says the attack still works: ${oneLine(verdict.reason, 240) || 'no reason given'}`);
|
|
676
|
+
result.proofs.checker_agrees = true;
|
|
677
|
+
|
|
678
|
+
// Last look before the commit: the temp worktree is on the fix branch and
|
|
679
|
+
// nothing moved the branch you started from.
|
|
680
|
+
if (!branchOk()) return fail('the fix branch moved or the temp copy left it before the commit');
|
|
681
|
+
if (refSha(root, `refs/heads/${from}`) !== start) return fail(`${from} moved while the fix ran`);
|
|
682
|
+
git(wt, ['add', '-A', '--', ...files]);
|
|
683
|
+
try {
|
|
684
|
+
git(wt, ['commit', '-q', '-m', commitMessage(record, engines.verifier)]);
|
|
685
|
+
} catch (e) {
|
|
686
|
+
return fail(`the commit was blocked: ${oneLine(e.message, 200)}`);
|
|
687
|
+
}
|
|
688
|
+
made = refSha(wt, 'HEAD');
|
|
689
|
+
if (refSha(root, ref) !== made || refSha(root, `${made}^`) !== start) return fail('the commit did not land on the fix branch as expected');
|
|
690
|
+
if (refSha(root, `refs/heads/${from}`) !== start) return fail(`${from} moved while the fix ran`);
|
|
691
|
+
result.commit = made;
|
|
692
|
+
result.committed = true;
|
|
693
|
+
result.ok = true;
|
|
694
|
+
removeWorktree();
|
|
695
|
+
return result;
|
|
696
|
+
} catch (e) {
|
|
697
|
+
return fail(`the fix stopped: ${oneLine(e.message, 200)}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------- pr
|
|
703
|
+
|
|
704
|
+
function githubRemote(root) {
|
|
705
|
+
const url = git(root, ['remote', 'get-url', 'origin'], { allowFail: true });
|
|
706
|
+
if (!url) return null;
|
|
707
|
+
return /^(?:https:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)/.test(url) ? url : null;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function defaultRun(cmd, args, { cwd }) {
|
|
711
|
+
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
712
|
+
return { code: r.status === null ? -1 : r.status, stdout: r.stdout || '', stderr: `${r.stderr || ''}${r.error ? r.error.message : ''}` };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function prBody(record, verifier) {
|
|
716
|
+
return [
|
|
717
|
+
`Sentinel confirmed a security problem in this repository: ${oneLine(record.title, 200).replace(/[.\s]+$/, '')}.`,
|
|
718
|
+
'',
|
|
719
|
+
'This branch holds the smallest fix plus a regression test.',
|
|
720
|
+
'',
|
|
721
|
+
'- the new test fails on the old code',
|
|
722
|
+
'- the full test suite passes with the fix',
|
|
723
|
+
`- ${verifier} read the fixed source and found the attack no longer works`,
|
|
724
|
+
'',
|
|
725
|
+
'Made by atris sentinel fix. Nothing was merged. Review before merging.',
|
|
726
|
+
].join('\n');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function openPr(root, result, record, { run = defaultRun, verifier }) {
|
|
730
|
+
if (!githubRemote(root)) return { ok: false, reason: 'this repository has no github remote named origin' };
|
|
731
|
+
const push = run('git', ['push', '-u', 'origin', result.branch], { cwd: root });
|
|
732
|
+
if (push.code !== 0) return { ok: false, reason: `could not push ${result.branch}: ${oneLine(push.stderr, 160)}` };
|
|
733
|
+
const title = `Fix: ${oneLine(record.title, 200).replace(/[.\s]+$/, '')}`.slice(0, 100);
|
|
734
|
+
const pr = run('gh', ['pr', 'create', '--base', result.from, '--head', result.branch, '--title', title, '--body', prBody(record, verifier)], { cwd: root });
|
|
735
|
+
if (pr.code !== 0) return { ok: false, pushed: true, reason: `gh could not open the pull request: ${oneLine(pr.stderr, 160)}` };
|
|
736
|
+
const url = (pr.stdout.match(/https:\/\/\S+/) || [])[0] || '';
|
|
737
|
+
return { ok: true, pushed: true, url };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ---------------------------------------------------------------- event
|
|
741
|
+
|
|
742
|
+
function fixEvent(root, record, result) {
|
|
743
|
+
const events = require('./sentinel-events');
|
|
744
|
+
const repo = events.repoName(root);
|
|
745
|
+
const subject = oneLine(record.title, 80).replace(/[.\s]+$/, '');
|
|
746
|
+
const what = subject ? subject.charAt(0).toLowerCase() + subject.slice(1) : '';
|
|
747
|
+
const title = events.fillPlain('Prepared a fix for {what} on a new branch.', { what: ['word', what, 'a confirmed security problem'] });
|
|
748
|
+
return events.makeEvent({ kind: 'scan_finished', repo, severity: 'info', title, status: 'resolved', fingerprint: events.fingerprint('scan_finished', repo, 'fix', record.fingerprint, result.commit || '') });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
module.exports = {
|
|
752
|
+
BRANCH_PREFIX,
|
|
753
|
+
HOSTED_CHECK_MAX_BYTES,
|
|
754
|
+
HOSTED_OUTPUT_TOKENS,
|
|
755
|
+
shortId,
|
|
756
|
+
branchFor,
|
|
757
|
+
isHosted,
|
|
758
|
+
git,
|
|
759
|
+
branchExists,
|
|
760
|
+
changedPaths,
|
|
761
|
+
isTestFile,
|
|
762
|
+
detectTestCommand,
|
|
763
|
+
confirmedFindings,
|
|
764
|
+
matchFinding,
|
|
765
|
+
citedFiles,
|
|
766
|
+
builderPrompt,
|
|
767
|
+
resolveFixEngines,
|
|
768
|
+
defaultRunBuilder,
|
|
769
|
+
defaultRunChecker,
|
|
770
|
+
hostedWritable,
|
|
771
|
+
hostedSourceBlock,
|
|
772
|
+
fixOne,
|
|
773
|
+
realFailure,
|
|
774
|
+
openPr,
|
|
775
|
+
fixEvent,
|
|
776
|
+
commitMessage,
|
|
777
|
+
};
|