atris 3.82.10 → 3.82.12
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 +14 -0
- package/commands/memory-live.js +373 -0
- package/commands/memory.js +205 -0
- package/commands/security-review.js +23 -0
- package/commands/sentinel-fix.js +8 -0
- package/commands/sentinel-powers.js +25 -4
- package/commands/sentinel-record.js +139 -0
- package/lib/known-commands.js +1 -1
- package/lib/memory-files.js +226 -0
- package/lib/memory-folder.js +602 -0
- package/lib/memory-live.js +356 -0
- package/lib/sentinel-cloud.js +1 -0
- package/lib/sentinel-record.js +877 -0
- package/lib/workspace-safety.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The sentinel security record: one append-only log per project of every
|
|
4
|
+
// review, recheck, agent check, dependency watch, guard summary and fix.
|
|
5
|
+
// A small company hands the exported file to a customer's security reviewer.
|
|
6
|
+
//
|
|
7
|
+
// The guarantee:
|
|
8
|
+
// every entry holds the SHA-256 of the entry before it and an Ed25519
|
|
9
|
+
// signature from this project's key. Editing, deleting, reordering or
|
|
10
|
+
// inserting an entry breaks the chain or a signature. The verifier
|
|
11
|
+
// names the first entry that broke. An export also carries a signed head
|
|
12
|
+
// (entry count and last hash). Cutting entries off the end of an
|
|
13
|
+
// exported file breaks it too.
|
|
14
|
+
// the signature proves the record came from the holder of this project's
|
|
15
|
+
// key and was not edited since. the key fingerprint printed by
|
|
16
|
+
// `atris sentinel record key` is what a reviewer compares against.
|
|
17
|
+
//
|
|
18
|
+
// Accepted limits:
|
|
19
|
+
// it does not prove the checks were thorough or that the code is safe.
|
|
20
|
+
// it records what sentinel ran and what it found, nothing more
|
|
21
|
+
// it does not prove when an entry was written. times come from this
|
|
22
|
+
// machine's clock. Backfilled entries carry their original dates
|
|
23
|
+
// the key holder can build a fresh record from scratch or drop the newest
|
|
24
|
+
// entries and sign a new head. a reviewer who kept an earlier export can
|
|
25
|
+
// catch that: its last hash must still appear in the new one
|
|
26
|
+
// a reviewer who takes the public key from the file itself instead of
|
|
27
|
+
// from the company can be fooled by a record signed with some other key
|
|
28
|
+
// the key sits in a 0600 file on this machine. anyone who can read it can
|
|
29
|
+
// sign entries
|
|
30
|
+
// the exported html page holds no record text of its own. it draws the
|
|
31
|
+
// summary and timeline from the embedded record only after the chain and
|
|
32
|
+
// every signature check out. someone who rewrites the page's verifier
|
|
33
|
+
// script can still fake what it shows. the check to trust is
|
|
34
|
+
// `atris sentinel record verify FILE` or a verifier the reviewer gets
|
|
35
|
+
// from atris directly
|
|
36
|
+
//
|
|
37
|
+
// Identity: a project is its git remote url (without any user or password)
|
|
38
|
+
// plus its repo root path, hashed. Two folders with the same name never
|
|
39
|
+
// share a key or a record. The readable name is for display only.
|
|
40
|
+
//
|
|
41
|
+
// Privacy: entries hold plain sentences, counts, file:line locations and
|
|
42
|
+
// short fingerprints only. Every text field goes through plain() and the
|
|
43
|
+
// strict secret scanner in lib/sentinel-cloud.js before it is written, and
|
|
44
|
+
// the whole entry is scanned once more. Code, secrets and file contents
|
|
45
|
+
// never reach an entry.
|
|
46
|
+
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const os = require('os');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const crypto = require('crypto');
|
|
51
|
+
|
|
52
|
+
const FORMAT = 'atris-sentinel-record';
|
|
53
|
+
const FORMAT_VERSION = 1;
|
|
54
|
+
const ZERO_HASH = '0'.repeat(64);
|
|
55
|
+
const KINDS = ['review', 'recheck', 'agent_check', 'watch', 'guard', 'fix'];
|
|
56
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
57
|
+
const STATUSES = ['open', 'fixed', 'needs_validation', 'fix_prepared'];
|
|
58
|
+
const KIND_WORDS = { review: 'review', recheck: 'recheck', agent_check: 'agent check', watch: 'dependency watch', guard: 'guard summary', fix: 'fix' };
|
|
59
|
+
const MAX_FINDINGS = 500;
|
|
60
|
+
|
|
61
|
+
function cloud() { return require('./sentinel-cloud'); }
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- paths
|
|
64
|
+
|
|
65
|
+
function homeOf(opts = {}) {
|
|
66
|
+
return opts.homeDir || (opts.env && opts.env.HOME) || process.env.HOME || os.homedir();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function auditHome(opts = {}) {
|
|
70
|
+
if (opts.recordHome) return opts.recordHome;
|
|
71
|
+
const env = opts.env || process.env;
|
|
72
|
+
return env.ATRIS_SECURITY_AUDIT_HOME || path.join(homeOf(opts), '.atris', 'security-audit');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function projectName(root) {
|
|
76
|
+
let name = '';
|
|
77
|
+
try { name = require('./security-audit').repoName(path.resolve(root)); } catch {}
|
|
78
|
+
return /^[A-Za-z0-9._-]{1,100}$/.test(name) ? name : 'project';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const idCache = new Map();
|
|
82
|
+
function projectId(root) {
|
|
83
|
+
const abs = path.resolve(root);
|
|
84
|
+
if (idCache.has(abs)) return idCache.get(abs);
|
|
85
|
+
const { execFileSync } = require('child_process');
|
|
86
|
+
const git = (args) => { try { return execFileSync('git', args, { cwd: abs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
87
|
+
let top = git(['rev-parse', '--show-toplevel']) || abs;
|
|
88
|
+
try { top = fs.realpathSync(top); } catch {}
|
|
89
|
+
const remote = git(['remote', 'get-url', 'origin']).replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, '$1');
|
|
90
|
+
const id = `p-${sha256(`${remote}\u0000${top}`).slice(0, 16)}`;
|
|
91
|
+
idCache.set(abs, id);
|
|
92
|
+
return id;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function recordPath(root, opts = {}) {
|
|
96
|
+
return path.join(auditHome(opts), 'records', opts.projectId || projectId(root), 'record.jsonl');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function keyPath(id, opts = {}) {
|
|
100
|
+
return path.join(homeOf(opts), '.atris', 'sentinel', 'record-keys', `${id}.json`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------- canonical form
|
|
104
|
+
|
|
105
|
+
// Sorted keys, no spaces. The browser verifier below uses the same code.
|
|
106
|
+
function canonical(v) {
|
|
107
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
108
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
109
|
+
return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sha256(text) {
|
|
113
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
|
117
|
+
function fromB64url(s) { return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
|
|
118
|
+
|
|
119
|
+
function keyFingerprint(publicKeyB64) {
|
|
120
|
+
return sha256Bytes(fromB64url(publicKeyB64)).slice(0, 32).match(/.{4}/g).join(' ');
|
|
121
|
+
}
|
|
122
|
+
function sha256Bytes(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); }
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------- key
|
|
125
|
+
|
|
126
|
+
// One Ed25519 key per project, made on first use, kept outside the repo.
|
|
127
|
+
function loadKey(id, opts = {}) {
|
|
128
|
+
const project = id;
|
|
129
|
+
const file = keyPath(id, opts);
|
|
130
|
+
let data = null;
|
|
131
|
+
try { data = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
|
|
132
|
+
if (data && data.private_pem && data.public_key) {
|
|
133
|
+
return { file, project, privateKey: crypto.createPrivateKey(data.private_pem), publicKey: data.public_key, fingerprint: keyFingerprint(data.public_key), created: false };
|
|
134
|
+
}
|
|
135
|
+
if (opts.create === false) return null;
|
|
136
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
|
137
|
+
const pub = publicKey.export({ format: 'jwk' }).x;
|
|
138
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
139
|
+
try { fs.chmodSync(path.dirname(file), 0o700); } catch {}
|
|
140
|
+
const body = JSON.stringify({ project_id: id, name: opts.name || '', created_at: new Date().toISOString(), public_key: pub, private_pem: privateKey.export({ format: 'pem', type: 'pkcs8' }) }, null, 2);
|
|
141
|
+
const fd = fs.openSync(file, 'wx', 0o600);
|
|
142
|
+
try { fs.writeSync(fd, `${body}\n`); } finally { fs.closeSync(fd); }
|
|
143
|
+
fs.chmodSync(file, 0o600);
|
|
144
|
+
return { file, project, privateKey, publicKey: pub, fingerprint: keyFingerprint(pub), created: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function signText(text, privateKey) {
|
|
148
|
+
return b64url(crypto.sign(null, Buffer.from(text, 'utf8'), privateKey));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------- cleaning
|
|
152
|
+
|
|
153
|
+
function cleanText(value, max = 300) {
|
|
154
|
+
const { plain, FALLBACK_SENTENCE } = cloud();
|
|
155
|
+
const s = plain(value, max);
|
|
156
|
+
return s && s !== FALLBACK_SENTENCE ? s : '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function cleanLocation(loc) {
|
|
160
|
+
const s = String(loc || '').trim();
|
|
161
|
+
const m = s.match(/^([\w./@+-]+?)(?::(\d{1,7}))?$/);
|
|
162
|
+
if (!m || s.length > 280 || m[1].split('/').includes('..') || m[1].startsWith('/')) return '';
|
|
163
|
+
if (cloud().strictSecretHit(s)) return '';
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cleanCount(v) {
|
|
168
|
+
const n = Number(v);
|
|
169
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cleanCounts(obj, depth = 0) {
|
|
173
|
+
const out = {};
|
|
174
|
+
if (!obj || typeof obj !== 'object') return out;
|
|
175
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
176
|
+
if (!/^[a-z][a-z0-9_-]{0,39}$/.test(k)) continue;
|
|
177
|
+
if (v && typeof v === 'object' && depth === 0) out[k] = cleanCounts(v, 1);
|
|
178
|
+
else if (typeof v !== 'object') out[k] = cleanCount(v);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function cleanSeverity(s) {
|
|
184
|
+
const v = String(s || '').toLowerCase();
|
|
185
|
+
if (v === 'informational') return 'info';
|
|
186
|
+
return SEVERITIES.includes(v) ? v : 'unrated';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cleanDate(d) {
|
|
190
|
+
const t = Date.parse(d);
|
|
191
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function findingId(fingerprint) {
|
|
195
|
+
const s = String(fingerprint || '');
|
|
196
|
+
if (/^f-[0-9a-f]{12}$/.test(s)) return s;
|
|
197
|
+
return `f-${sha256(s).slice(0, 12)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function cleanFinding(f) {
|
|
201
|
+
if (!f) return null;
|
|
202
|
+
const out = { id: findingId(f.id || f.fingerprint), severity: cleanSeverity(f.severity), status: STATUSES.includes(f.status) ? f.status : 'open' };
|
|
203
|
+
const title = cleanText(f.title, 300);
|
|
204
|
+
if (title) out.title = title;
|
|
205
|
+
const loc = cleanLocation(f.location);
|
|
206
|
+
if (loc) out.location = loc;
|
|
207
|
+
if (Array.isArray(f.locations)) {
|
|
208
|
+
const locs = [...new Set(f.locations.map(cleanLocation).filter(Boolean))].slice(0, 20);
|
|
209
|
+
if (locs.length) out.locations = locs;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Build a clean, unsigned entry body from raw input. Throws when the result
|
|
215
|
+
// would still hold anything that looks like a secret.
|
|
216
|
+
function cleanEntry(input, opts = {}) {
|
|
217
|
+
if (!KINDS.includes(input.kind)) throw new Error(`unknown record kind: ${input.kind}`);
|
|
218
|
+
const at = cleanDate(input.at) || new Date().toISOString();
|
|
219
|
+
const body = {
|
|
220
|
+
kind: input.kind,
|
|
221
|
+
at,
|
|
222
|
+
summary: cleanText(input.summary, 500) || `Sentinel ran a ${KIND_WORDS[input.kind]}.`,
|
|
223
|
+
counts: cleanCounts(input.counts),
|
|
224
|
+
findings: (Array.isArray(input.findings) ? input.findings : []).slice(0, MAX_FINDINGS).map(cleanFinding).filter(Boolean),
|
|
225
|
+
exam_version: /^[a-z0-9 +._-]{1,80}$/i.test(String(input.exam_version || '')) ? String(input.exam_version) : examVersion(),
|
|
226
|
+
atris_version: /^[0-9A-Za-z.+-]{1,40}$/.test(String(input.atris_version || '')) ? String(input.atris_version) : atrisVersion(),
|
|
227
|
+
};
|
|
228
|
+
if (input.backfilled) { body.backfilled = true; body.recorded_at = cleanDate(opts.now) || new Date().toISOString(); }
|
|
229
|
+
if (/^run-\d{1,6}$/.test(String(input.source_run || ''))) body.source_run = String(input.source_run);
|
|
230
|
+
if (['project', 'project and home'].includes(input.scope)) body.scope = input.scope;
|
|
231
|
+
if (cloud().strictSecretHit(canonical(body).replace(/[{}[\]",]/g, '\n'))) throw new Error('an entry looked like it held a secret. it was not written');
|
|
232
|
+
return body;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let cachedExam = null;
|
|
236
|
+
function examVersion() {
|
|
237
|
+
if (cachedExam) return cachedExam;
|
|
238
|
+
const root = path.join(__dirname, '..');
|
|
239
|
+
let rules = 'none';
|
|
240
|
+
try { rules = sha256Bytes(fs.readFileSync(path.join(root, 'lib', 'sentinel-rules.json'))).slice(0, 12); } catch {}
|
|
241
|
+
let exam = '';
|
|
242
|
+
try {
|
|
243
|
+
const dir = path.join(root, 'atris', 'sentinel-bench');
|
|
244
|
+
const h = crypto.createHash('sha256');
|
|
245
|
+
const names = fs.readdirSync(dir).filter((n) => n.endsWith('.jsonl')).sort();
|
|
246
|
+
for (const n of names) h.update(n).update(fs.readFileSync(path.join(dir, n)));
|
|
247
|
+
if (names.length) exam = ` exam-${h.digest('hex').slice(0, 12)}`;
|
|
248
|
+
} catch {}
|
|
249
|
+
cachedExam = `rules-${rules}${exam}`;
|
|
250
|
+
return cachedExam;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function atrisVersion() {
|
|
254
|
+
try { return require('../package.json').version; } catch { return 'unknown'; }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------- read and append
|
|
258
|
+
|
|
259
|
+
function readEntries(file) {
|
|
260
|
+
let text = '';
|
|
261
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const line of text.split('\n')) {
|
|
264
|
+
if (!line.trim()) continue;
|
|
265
|
+
out.push(JSON.parse(line));
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function withLock(file, fn) {
|
|
271
|
+
const lock = `${file}.lock`;
|
|
272
|
+
const start = Date.now();
|
|
273
|
+
for (;;) {
|
|
274
|
+
try { fs.writeFileSync(lock, String(process.pid), { flag: 'wx' }); break; } catch (e) {
|
|
275
|
+
if (e.code !== 'EEXIST') throw e;
|
|
276
|
+
try { if (Date.now() - fs.statSync(lock).mtimeMs > 30000) { fs.unlinkSync(lock); continue; } } catch {}
|
|
277
|
+
if (Date.now() - start > 5000) throw new Error('the record is busy. another sentinel command is writing to it');
|
|
278
|
+
const until = Date.now() + 25; while (Date.now() < until) { /* short wait */ }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
try { return fn(); } finally { try { fs.unlinkSync(lock); } catch {} }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sealEntry(body, prev, seq, key) {
|
|
285
|
+
const unsigned = { ...body, seq, prev };
|
|
286
|
+
const hash = sha256(canonical(unsigned));
|
|
287
|
+
return { ...unsigned, hash, sig: signText(hash, key.privateKey) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Append one entry. Returns the written entry.
|
|
291
|
+
function append(root, input, opts = {}) {
|
|
292
|
+
const id = opts.projectId || projectId(root);
|
|
293
|
+
const file = recordPath(root, { ...opts, projectId: id });
|
|
294
|
+
const body = cleanEntry(input, opts);
|
|
295
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
296
|
+
return withLock(file, () => {
|
|
297
|
+
const key = loadKey(id, { ...opts, name: projectName(root) });
|
|
298
|
+
const entries = readEntries(file);
|
|
299
|
+
const last = entries[entries.length - 1];
|
|
300
|
+
const entry = sealEntry(body, last ? last.hash : ZERO_HASH, entries.length + 1, key);
|
|
301
|
+
fs.appendFileSync(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
302
|
+
return entry;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The hook every sentinel command calls. Never throws. Off inside node
|
|
307
|
+
// --test unless the test names its own record home.
|
|
308
|
+
function appendSafe(root, input, opts = {}) {
|
|
309
|
+
if (opts.record === false) return { ok: true, skipped: true };
|
|
310
|
+
const env = opts.env || process.env;
|
|
311
|
+
if ((process.env.NODE_TEST_CONTEXT || env.ATRIS_SENTINEL_RECORD === 'off') && !opts.recordHome) return { ok: true, skipped: true };
|
|
312
|
+
try {
|
|
313
|
+
const list = Array.isArray(input) ? input : [input];
|
|
314
|
+
const written = list.map((x) => append(root, x, opts));
|
|
315
|
+
return { ok: true, entries: written };
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return { ok: false, reason: String((e && e.message) || e).split('\n')[0] };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function warnLine(res) {
|
|
322
|
+
if (!res || res.ok) return '';
|
|
323
|
+
return ` warning: could not add this to the security record: ${res.reason}. the check itself is done`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------- entries from sentinel results
|
|
327
|
+
|
|
328
|
+
function runFindings(dir) {
|
|
329
|
+
const read = (n) => { try { return JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8')); } catch { return null; } };
|
|
330
|
+
const records = read('findings.json') || [];
|
|
331
|
+
const waiting = (read('carried-pending.json') || []).filter((r) => r && r.verdict === 'confirmed' && !records.some((x) => x && x.fingerprint === r.fingerprint));
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const r of [...records, ...waiting]) {
|
|
334
|
+
if (!r || !['confirmed', 'fixed', 'needs_validation'].includes(r.verdict)) continue;
|
|
335
|
+
const trace = Array.isArray(r.trace) ? r.trace : [];
|
|
336
|
+
const sink = trace[trace.length - 1] || {};
|
|
337
|
+
const loc = sink && typeof sink.file === 'string' ? `${sink.file}${Number.isInteger(sink.line) && sink.line > 0 ? `:${sink.line}` : ''}` : '';
|
|
338
|
+
out.push({
|
|
339
|
+
fingerprint: r.fingerprint,
|
|
340
|
+
severity: (r.severity && r.severity.overall) || 'unrated',
|
|
341
|
+
status: r.verdict === 'confirmed' ? 'open' : r.verdict,
|
|
342
|
+
title: r.title,
|
|
343
|
+
location: loc,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function bySeverity(findings, status) {
|
|
350
|
+
const out = {};
|
|
351
|
+
for (const s of [...SEVERITIES, 'unrated']) out[s] = 0;
|
|
352
|
+
for (const f of findings) if (f.status === status) out[cleanSeverity(f.severity)] += 1;
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function plural(n, one, many) { return `${n} ${n === 1 ? one : many}`; }
|
|
357
|
+
|
|
358
|
+
// A review or recheck run folder as one entry.
|
|
359
|
+
function entryFromRun(dir, { backfilled = false } = {}) {
|
|
360
|
+
let meta = null;
|
|
361
|
+
try { meta = JSON.parse(fs.readFileSync(path.join(dir, 'run-metadata.json'), 'utf8')); } catch {}
|
|
362
|
+
if (!meta) throw new Error(`no run-metadata.json in ${dir}`);
|
|
363
|
+
const kind = meta.mode === 'recheck' ? 'recheck' : 'review';
|
|
364
|
+
const findings = runFindings(dir);
|
|
365
|
+
const open = findings.filter((f) => f.status === 'open').length;
|
|
366
|
+
const fixed = findings.filter((f) => f.status === 'fixed').length;
|
|
367
|
+
const needs = findings.filter((f) => f.status === 'needs_validation').length;
|
|
368
|
+
const cov = meta.coverage || {};
|
|
369
|
+
const complete = meta.run_status === 'complete';
|
|
370
|
+
const what = kind === 'recheck' ? 'Sentinel rechecked earlier findings against the current code' : 'Sentinel reviewed the project';
|
|
371
|
+
let summary = `${what}. ${plural(open, 'problem is', 'problems are')} open`;
|
|
372
|
+
if (fixed) summary += `. ${plural(fixed, 'problem was', 'problems were')} confirmed fixed`;
|
|
373
|
+
if (needs) summary += `. ${plural(needs, 'possible problem needs', 'possible problems need')} a person to check`;
|
|
374
|
+
summary += '.';
|
|
375
|
+
if (!complete) summary += ' This run did not finish.';
|
|
376
|
+
return {
|
|
377
|
+
kind,
|
|
378
|
+
at: meta.finished_at || meta.started_at,
|
|
379
|
+
backfilled,
|
|
380
|
+
source_run: meta.run_id || path.basename(dir),
|
|
381
|
+
summary,
|
|
382
|
+
counts: {
|
|
383
|
+
open: bySeverity(findings, 'open'),
|
|
384
|
+
fixed: bySeverity(findings, 'fixed'),
|
|
385
|
+
needs_validation: needs,
|
|
386
|
+
areas_in_scope: cleanCount(cov.in_scope),
|
|
387
|
+
areas_done: cleanCount(cov.done),
|
|
388
|
+
complete: complete ? 1 : 0,
|
|
389
|
+
},
|
|
390
|
+
findings,
|
|
391
|
+
...(backfilled ? { exam_version: 'not recorded at the time', atris_version: 'unknown' } : {}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Ids still open from earlier snapshot checks of this kind and scope.
|
|
396
|
+
function openFromKind(root, kind, scope, opts) {
|
|
397
|
+
let entries = [];
|
|
398
|
+
try { entries = readEntries(recordPath(root, opts)); } catch { return new Map(); }
|
|
399
|
+
const state = new Map();
|
|
400
|
+
for (const e of entries) {
|
|
401
|
+
if (e.kind !== kind || (scope && e.scope && e.scope !== scope)) continue;
|
|
402
|
+
for (const f of e.findings || []) state.set(f.id, f);
|
|
403
|
+
}
|
|
404
|
+
return new Map([...state].filter(([, f]) => f.status === 'open'));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function entryFromAgents(root, result, opts = {}) {
|
|
408
|
+
const scope = result.home ? 'project and home' : 'project';
|
|
409
|
+
const now = (result.findings || []).map((f) => ({ fingerprint: f.fingerprint, severity: f.severity, status: 'open', title: f.title, location: f.file ? `${f.file}${f.line ? `:${f.line}` : ''}` : '' }));
|
|
410
|
+
const seen = new Set(now.map((f) => findingId(f.fingerprint)));
|
|
411
|
+
const gone = [...openFromKind(root, 'agent_check', scope, opts).values()].filter((f) => !seen.has(f.id)).map((f) => ({ ...f, status: 'fixed' }));
|
|
412
|
+
let summary = `Sentinel checked the agent setup in this ${scope === 'project' ? 'project' : 'project and the home folder'}. The grade is ${result.grade}. ${plural(now.length, 'problem is', 'problems are')} open.`;
|
|
413
|
+
if (gone.length) summary += ` ${plural(gone.length, 'earlier problem is', 'earlier problems are')} no longer found.`;
|
|
414
|
+
return { kind: 'agent_check', scope, summary, counts: { open: bySeverity(now, 'open'), fixed: bySeverity(gone, 'fixed'), files: cleanCount(result.files) }, findings: [...now, ...gone] };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// A threat is marked fixed only under the watch's per-lockfile rule: every
|
|
418
|
+
// lockfile the record saw it in was read this scan, none of them holds it
|
|
419
|
+
// now. On a side branch it must also be gone from the default branch.
|
|
420
|
+
// A threat with no known lockfile, or whose lockfile was moved, deleted or
|
|
421
|
+
// not read, stays open. Absence alone never counts as a fix.
|
|
422
|
+
function watchHistory(root, opts) {
|
|
423
|
+
let entries = [];
|
|
424
|
+
try { entries = readEntries(recordPath(root, opts)); } catch {}
|
|
425
|
+
const state = new Map();
|
|
426
|
+
for (const e of entries) {
|
|
427
|
+
if (e.kind !== 'watch') continue;
|
|
428
|
+
for (const f of e.findings || []) {
|
|
429
|
+
const s = state.get(f.id) || { f, lockfiles: new Set() };
|
|
430
|
+
s.f = f;
|
|
431
|
+
for (const l of [f.location, ...(f.locations || [])]) if (l) s.lockfiles.add(l);
|
|
432
|
+
state.set(f.id, s);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return state;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function entryFromWatch(root, result, { branch = null, ...opts } = {}) {
|
|
439
|
+
if (result.offline) {
|
|
440
|
+
return { kind: 'watch', summary: 'Sentinel could not reach the known attacks database this time. Nothing was checked.', counts: { complete: 0 }, findings: [] };
|
|
441
|
+
}
|
|
442
|
+
const read = new Set((result.lockfiles || []).map((l) => l && l.file).filter(Boolean));
|
|
443
|
+
const groups = new Map();
|
|
444
|
+
for (const a of result.affected || []) {
|
|
445
|
+
const id = findingId(require('./sentinel-events').fingerprint('threat', a.ecosystem, a.name, a.version));
|
|
446
|
+
const g = groups.get(id) || { id, severity: a.severity, status: 'open', locations: [] };
|
|
447
|
+
if (a.lockfile && !g.locations.includes(a.lockfile)) g.locations.push(a.lockfile);
|
|
448
|
+
groups.set(id, g);
|
|
449
|
+
}
|
|
450
|
+
const now = [...groups.values()].map((g) => ({ ...g, location: g.locations[0] || '' }));
|
|
451
|
+
const live = branch && !branch.onDefault ? (branch.liveFingerprints || null) : undefined;
|
|
452
|
+
const heldByBranch = (id) => live === null || (live instanceof Set && live.has(id));
|
|
453
|
+
const gone = [];
|
|
454
|
+
let held = 0;
|
|
455
|
+
for (const [id, h] of watchHistory(root, opts)) {
|
|
456
|
+
if (groups.has(id) || h.f.status !== 'open') continue;
|
|
457
|
+
const lfs = [...h.lockfiles];
|
|
458
|
+
if (lfs.length && lfs.every((l) => read.has(l)) && !heldByBranch(id)) gone.push({ ...h.f, locations: lfs, status: 'fixed' });
|
|
459
|
+
else held += 1;
|
|
460
|
+
}
|
|
461
|
+
let summary = `Sentinel checked ${plural(read.size, 'lockfile', 'lockfiles')} against known attacks. ${plural(now.length, 'affected package is', 'affected packages are')} open.`;
|
|
462
|
+
if (gone.length) summary += ` ${plural(gone.length, 'earlier threat is', 'earlier threats are')} gone from every lockfile it was in.`;
|
|
463
|
+
if (held) summary += ` ${plural(held, 'earlier threat stays', 'earlier threats stay')} open. This check could not confirm the fix in every lockfile it was in.`;
|
|
464
|
+
return { kind: 'watch', summary, counts: { open: bySeverity(now, 'open'), fixed: bySeverity(gone, 'fixed'), held, lockfiles: read.size, complete: result.incomplete ? 0 : 1 }, findings: [...now, ...gone] };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Guard blocks for this project since the last guard entry. Counts only.
|
|
468
|
+
function entryFromGuard(root, opts = {}) {
|
|
469
|
+
const env = opts.env || process.env;
|
|
470
|
+
let project = projectName(root);
|
|
471
|
+
try { project = require('./sentinel-events').repoName(root); } catch {}
|
|
472
|
+
let entries = [];
|
|
473
|
+
try { entries = readEntries(recordPath(root, opts)); } catch {}
|
|
474
|
+
const lastGuard = [...entries].reverse().find((e) => e.kind === 'guard');
|
|
475
|
+
const since = lastGuard ? Date.parse(lastGuard.at) : 0;
|
|
476
|
+
const now = opts.now ? Date.parse(opts.now) : Date.now();
|
|
477
|
+
let text = '';
|
|
478
|
+
try { text = fs.readFileSync(path.join(homeOf({ ...opts, env }), '.atris', 'sentinel', 'guard.log'), 'utf8'); } catch {}
|
|
479
|
+
const byRule = {};
|
|
480
|
+
let total = 0;
|
|
481
|
+
for (const line of text.split('\n')) {
|
|
482
|
+
if (!line.trim()) continue;
|
|
483
|
+
let r;
|
|
484
|
+
try { r = JSON.parse(line); } catch { continue; }
|
|
485
|
+
const t = Date.parse(r.at);
|
|
486
|
+
if (!Number.isFinite(t) || t <= since || t > now) continue;
|
|
487
|
+
if (r.repo && r.repo !== project) continue;
|
|
488
|
+
const rule = /^[a-z][a-z0-9-]{0,39}$/.test(String(r.rule || '')) ? r.rule : 'other';
|
|
489
|
+
byRule[rule] = (byRule[rule] || 0) + 1;
|
|
490
|
+
total += 1;
|
|
491
|
+
}
|
|
492
|
+
const window = lastGuard ? 'since the last guard summary' : 'in its log';
|
|
493
|
+
return { kind: 'guard', at: new Date(now).toISOString(), summary: `The sentinel guard stopped ${plural(total, 'agent action', 'agent actions')} in this project ${window}.`, counts: { blocked: total, blocked_by_rule: byRule }, findings: [] };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function entryFromFix(record, res) {
|
|
497
|
+
const sev = record.severity && record.severity.overall;
|
|
498
|
+
return {
|
|
499
|
+
kind: 'fix',
|
|
500
|
+
summary: res.ok
|
|
501
|
+
? 'Sentinel prepared a fix on its own branch. The new test failed on the old code and passes with the fix. It stays open until a recheck confirms it.'
|
|
502
|
+
: 'Sentinel tried to prepare a fix and no fix was committed.',
|
|
503
|
+
counts: { prepared: res.ok ? 1 : 0 },
|
|
504
|
+
findings: [{ fingerprint: record.fingerprint, severity: sev, status: res.ok ? 'fix_prepared' : 'open', title: record.title }],
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Past review runs not yet in the record, oldest first, marked backfilled.
|
|
509
|
+
function backfill(root, opts = {}) {
|
|
510
|
+
const project = opts.project || projectName(root);
|
|
511
|
+
const dir = path.join(auditHome(opts), project);
|
|
512
|
+
let names = [];
|
|
513
|
+
try { names = fs.readdirSync(dir); } catch {}
|
|
514
|
+
const runs = names.map((n) => ({ n, m: n.match(/^run-(\d+)$/) })).filter((x) => x.m).sort((a, b) => Number(a.m[1]) - Number(b.m[1]));
|
|
515
|
+
const have = new Set(readEntries(recordPath(root, opts)).map((e) => e.source_run).filter(Boolean));
|
|
516
|
+
const added = [];
|
|
517
|
+
const skipped = [];
|
|
518
|
+
for (const r of runs) {
|
|
519
|
+
if (have.has(r.n)) { skipped.push(r.n); continue; }
|
|
520
|
+
let input;
|
|
521
|
+
try { input = entryFromRun(path.join(dir, r.n), { backfilled: true }); } catch (e) { skipped.push(r.n); continue; }
|
|
522
|
+
added.push(append(root, input, opts));
|
|
523
|
+
}
|
|
524
|
+
return { added, skipped };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------- summary
|
|
528
|
+
|
|
529
|
+
// Self-contained: the exported page embeds this function's source.
|
|
530
|
+
function summarize(entries) {
|
|
531
|
+
const KINDS = ['review', 'recheck', 'agent_check', 'watch', 'guard', 'fix'];
|
|
532
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
533
|
+
const cleanCount = (v) => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; };
|
|
534
|
+
const sorted = entries.map((e, i) => ({ e, i })).sort((a, b) => (Date.parse(a.e.at) - Date.parse(b.e.at)) || (a.i - b.i)).map((x) => x.e);
|
|
535
|
+
const checks = {};
|
|
536
|
+
for (const k of KINDS) checks[k] = 0;
|
|
537
|
+
const state = new Map();
|
|
538
|
+
const guard = { blocked: 0, by_rule: {} };
|
|
539
|
+
let backfilled = 0;
|
|
540
|
+
for (const e of sorted) {
|
|
541
|
+
checks[e.kind] = (checks[e.kind] || 0) + 1;
|
|
542
|
+
if (e.backfilled) backfilled += 1;
|
|
543
|
+
if (e.kind === 'guard') {
|
|
544
|
+
guard.blocked += cleanCount(e.counts && e.counts.blocked);
|
|
545
|
+
for (const [r, n] of Object.entries((e.counts && e.counts.blocked_by_rule) || {})) guard.by_rule[r] = (guard.by_rule[r] || 0) + cleanCount(n);
|
|
546
|
+
}
|
|
547
|
+
for (const f of e.findings || []) {
|
|
548
|
+
const s = state.get(f.id) || { id: f.id, severity: f.severity, status: null, first_open: null, fixed_at: null, title: f.title || '', location: f.location || '', kind: e.kind };
|
|
549
|
+
if (f.severity && f.severity !== 'unrated') s.severity = f.severity;
|
|
550
|
+
if (f.title) s.title = f.title;
|
|
551
|
+
if (f.location) s.location = f.location;
|
|
552
|
+
if (f.status === 'fix_prepared') { s.fix_prepared = e.at; if (!s.status) s.status = 'open'; }
|
|
553
|
+
else {
|
|
554
|
+
if ((f.status === 'open' || f.status === 'needs_validation') && !s.first_open) s.first_open = e.at;
|
|
555
|
+
if (f.status === 'fixed' && s.status !== 'fixed') s.fixed_at = e.at;
|
|
556
|
+
if (f.status !== 'fixed') s.fixed_at = null;
|
|
557
|
+
s.status = f.status;
|
|
558
|
+
}
|
|
559
|
+
state.set(f.id, s);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const open = {}; const fixed = {};
|
|
563
|
+
for (const s of [...SEVERITIES, 'unrated']) { open[s] = 0; fixed[s] = 0; }
|
|
564
|
+
const fixedList = [];
|
|
565
|
+
let needs = 0;
|
|
566
|
+
for (const s of state.values()) {
|
|
567
|
+
if (s.status === 'open') open[s.severity] = (open[s.severity] || 0) + 1;
|
|
568
|
+
else if (s.status === 'needs_validation') needs += 1;
|
|
569
|
+
else if (s.status === 'fixed') {
|
|
570
|
+
fixed[s.severity] = (fixed[s.severity] || 0) + 1;
|
|
571
|
+
fixedList.push({ ...s, open_ms: s.first_open && s.fixed_at ? Math.max(0, Date.parse(s.fixed_at) - Date.parse(s.first_open)) : null });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const last = sorted[sorted.length - 1] || null;
|
|
575
|
+
return { total: entries.length, checks, backfilled, last: last ? { kind: last.kind, at: last.at } : null, first: sorted[0] ? sorted[0].at : null, open, fixed, needs_validation: needs, fixed_list: fixedList, guard, findings: [...state.values()] };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function duration(ms) {
|
|
579
|
+
if (ms == null) return 'unknown';
|
|
580
|
+
const h = ms / 3600000;
|
|
581
|
+
if (h < 1) return `${Math.max(1, Math.round(ms / 60000))} min`;
|
|
582
|
+
if (h < 48) return `${Math.round(h)} h`;
|
|
583
|
+
return `${Math.round(h / 24)} days`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function sevLine(counts) {
|
|
587
|
+
return SEVERITIES.map((s) => `${counts[s] || 0} ${s}`).join(', ') + (counts.unrated ? `, ${counts.unrated} unrated` : '');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderShow(project, entries, key) {
|
|
591
|
+
const s = summarize(entries);
|
|
592
|
+
const L = [''];
|
|
593
|
+
L.push(` sentinel security record for ${project}`);
|
|
594
|
+
if (key) L.push(` signed with key ${key.fingerprint}`);
|
|
595
|
+
L.push('');
|
|
596
|
+
if (!entries.length) { L.push(' no entries yet. run atris sentinel or atris sentinel record backfill'); L.push(''); return L.join('\n'); }
|
|
597
|
+
const ran = KINDS.filter((k) => s.checks[k]).map((k) => `${s.checks[k]} ${KIND_WORDS[k]}${s.checks[k] === 1 ? '' : 's'}`).join(', ');
|
|
598
|
+
L.push(` checks run: ${ran}`);
|
|
599
|
+
if (s.backfilled) L.push(` ${s.backfilled} of them were added later from past runs, with their original dates`);
|
|
600
|
+
L.push(` last check: ${KIND_WORDS[s.last.kind]} on ${s.last.at.slice(0, 16).replace('T', ' ')} utc`);
|
|
601
|
+
L.push(` open: ${sevLine(s.open)}`);
|
|
602
|
+
if (s.needs_validation) L.push(` waiting for a person to check: ${s.needs_validation}`);
|
|
603
|
+
L.push(` fixed: ${sevLine(s.fixed)}`);
|
|
604
|
+
for (const f of s.fixed_list) L.push(` ${f.severity} ${f.location || f.id} stayed open ${duration(f.open_ms)}`);
|
|
605
|
+
const rules = Object.entries(s.guard.by_rule);
|
|
606
|
+
L.push(` guard: stopped ${plural(s.guard.blocked, 'action', 'actions')}${rules.length ? ` (${rules.map(([r, n]) => `${r} ${n}`).join(', ')})` : ''}`);
|
|
607
|
+
L.push('');
|
|
608
|
+
return L.join('\n');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------- export
|
|
612
|
+
|
|
613
|
+
function buildExport(root, opts = {}) {
|
|
614
|
+
const project = projectName(root);
|
|
615
|
+
const id = opts.projectId || projectId(root);
|
|
616
|
+
const entries = readEntries(recordPath(root, { ...opts, projectId: id }));
|
|
617
|
+
const key = loadKey(id, { ...opts, name: project });
|
|
618
|
+
const exported = cleanDate(opts.now) || new Date().toISOString();
|
|
619
|
+
const head = { project, project_id: id, count: entries.length, last_hash: entries.length ? entries[entries.length - 1].hash : ZERO_HASH, exported_at: exported };
|
|
620
|
+
const headHash = sha256(canonical(head));
|
|
621
|
+
return {
|
|
622
|
+
format: FORMAT,
|
|
623
|
+
format_version: FORMAT_VERSION,
|
|
624
|
+
project,
|
|
625
|
+
project_id: id,
|
|
626
|
+
public_key: key.publicKey,
|
|
627
|
+
key_fingerprint: key.fingerprint,
|
|
628
|
+
algorithm: 'Ed25519 over the SHA-256 hex of each entry in sorted-key JSON',
|
|
629
|
+
guarantee: 'The signature proves this record came from the holder of this key and was not edited, reordered or cut since. It does not prove the checks were thorough or that the code is safe.',
|
|
630
|
+
head: { ...head, hash: headHash, sig: signText(headHash, key.privateKey) },
|
|
631
|
+
entries,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ---------------------------------------------------------------- verify (node)
|
|
636
|
+
|
|
637
|
+
function brokenAt(i, entries, code, reason) {
|
|
638
|
+
return { ok: false, index: i, entry: i + 1, code, reason, label: `entry ${i + 1}` };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// The CLI check. Written apart from the browser one on purpose. The tests
|
|
642
|
+
// run both on the same files and expect the same answer.
|
|
643
|
+
function verifyDoc(doc) {
|
|
644
|
+
if (!doc || doc.format !== FORMAT || !Array.isArray(doc.entries) || !doc.head) return { ok: false, index: -1, entry: 0, code: 'format', reason: 'this is not an atris sentinel record', label: 'the file' };
|
|
645
|
+
let pub;
|
|
646
|
+
try {
|
|
647
|
+
const raw = fromB64url(doc.public_key);
|
|
648
|
+
if (raw.length !== 32) throw new Error('bad key');
|
|
649
|
+
pub = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: doc.public_key }, format: 'jwk' });
|
|
650
|
+
} catch { return { ok: false, index: -1, entry: 0, code: 'key', reason: 'the public key is not a valid ed25519 key', label: 'the key' }; }
|
|
651
|
+
if (keyFingerprint(doc.public_key) !== doc.key_fingerprint) return { ok: false, index: -1, entry: 0, code: 'key', reason: 'the key fingerprint does not match the public key', label: 'the key' };
|
|
652
|
+
const entries = doc.entries;
|
|
653
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
654
|
+
const e = entries[i];
|
|
655
|
+
if (!e || typeof e !== 'object') return brokenAt(i, entries, 'format', 'it is not a record entry');
|
|
656
|
+
if (e.seq !== i + 1) return brokenAt(i, entries, 'seq', 'its position number is wrong. entries were removed, added or moved');
|
|
657
|
+
const prev = i === 0 ? ZERO_HASH : entries[i - 1].hash;
|
|
658
|
+
if (e.prev !== prev) return brokenAt(i, entries, 'prev', 'it does not point to the entry before it');
|
|
659
|
+
const { hash, sig, ...body } = e;
|
|
660
|
+
if (sha256(canonical(body)) !== hash) return brokenAt(i, entries, 'hash', 'its contents were changed after it was written');
|
|
661
|
+
let good = false;
|
|
662
|
+
try { good = crypto.verify(null, Buffer.from(String(hash), 'utf8'), pub, fromB64url(sig)); } catch {}
|
|
663
|
+
if (!good) return brokenAt(i, entries, 'signature', 'its signature does not match this key');
|
|
664
|
+
}
|
|
665
|
+
const { hash: hh, sig: hs, ...head } = doc.head;
|
|
666
|
+
const last = entries.length ? entries[entries.length - 1].hash : ZERO_HASH;
|
|
667
|
+
let headOk = sha256(canonical(head)) === hh;
|
|
668
|
+
if (headOk) { try { headOk = crypto.verify(null, Buffer.from(String(hh), 'utf8'), pub, fromB64url(hs)); } catch { headOk = false; } }
|
|
669
|
+
if (!headOk || head.count !== entries.length || head.last_hash !== last || head.project !== doc.project || head.project_id !== doc.project_id) {
|
|
670
|
+
return { ok: false, index: entries.length, entry: entries.length, code: 'head', reason: 'the signed head does not match these entries. entries were cut from the end or the head was changed', label: 'the end of the record' };
|
|
671
|
+
}
|
|
672
|
+
return { ok: true, entries: entries.length, fingerprint: doc.key_fingerprint };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ---------------------------------------------------------------- verify (browser)
|
|
676
|
+
|
|
677
|
+
// Runs in the exported page with WebCrypto. Self-contained on purpose: the
|
|
678
|
+
// page embeds this function's source. Tests run it in Node's WebCrypto.
|
|
679
|
+
async function browserVerify(doc, subtle) {
|
|
680
|
+
const ZERO = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
681
|
+
const enc = new TextEncoder();
|
|
682
|
+
const canon = (v) => {
|
|
683
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
684
|
+
if (Array.isArray(v)) return '[' + v.map(canon).join(',') + ']';
|
|
685
|
+
return '{' + Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',') + '}';
|
|
686
|
+
};
|
|
687
|
+
const hex = (buf) => Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
688
|
+
const sha = async (text) => hex(await subtle.digest('SHA-256', enc.encode(text)));
|
|
689
|
+
const unb64 = (s) => {
|
|
690
|
+
const t = String(s).replace(/-/g, '+').replace(/_/g, '/');
|
|
691
|
+
const bin = atob(t + '==='.slice((t.length + 3) % 4));
|
|
692
|
+
const out = new Uint8Array(bin.length);
|
|
693
|
+
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
|
694
|
+
return out;
|
|
695
|
+
};
|
|
696
|
+
const broken = (i, entries, code, reason) => ({ ok: false, index: i, entry: i + 1, code, reason, label: 'entry ' + (i + 1) });
|
|
697
|
+
if (!doc || doc.format !== 'atris-sentinel-record' || !Array.isArray(doc.entries) || !doc.head) return { ok: false, index: -1, entry: 0, code: 'format', reason: 'this is not an atris sentinel record', label: 'the file' };
|
|
698
|
+
let key; let raw;
|
|
699
|
+
try {
|
|
700
|
+
raw = unb64(doc.public_key);
|
|
701
|
+
if (raw.length !== 32) throw new Error('bad key');
|
|
702
|
+
key = await subtle.importKey('raw', raw, { name: 'Ed25519' }, false, ['verify']);
|
|
703
|
+
} catch (e) { return { ok: false, index: -1, entry: 0, code: 'key', reason: 'the public key is not a valid ed25519 key', label: 'the key' }; }
|
|
704
|
+
const fp = hex(await subtle.digest('SHA-256', raw)).slice(0, 32).match(/.{4}/g).join(' ');
|
|
705
|
+
if (fp !== doc.key_fingerprint) return { ok: false, index: -1, entry: 0, code: 'key', reason: 'the key fingerprint does not match the public key', label: 'the key' };
|
|
706
|
+
const check = async (hash, sig) => { try { return await subtle.verify({ name: 'Ed25519' }, key, unb64(sig), enc.encode(String(hash))); } catch (e) { return false; } };
|
|
707
|
+
const entries = doc.entries;
|
|
708
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
709
|
+
const e = entries[i];
|
|
710
|
+
if (!e || typeof e !== 'object') return broken(i, entries, 'format', 'it is not a record entry');
|
|
711
|
+
if (e.seq !== i + 1) return broken(i, entries, 'seq', 'its position number is wrong. entries were removed, added or moved');
|
|
712
|
+
const prev = i === 0 ? ZERO : entries[i - 1].hash;
|
|
713
|
+
if (e.prev !== prev) return broken(i, entries, 'prev', 'it does not point to the entry before it');
|
|
714
|
+
const body = {};
|
|
715
|
+
for (const k of Object.keys(e)) if (k !== 'hash' && k !== 'sig') body[k] = e[k];
|
|
716
|
+
if (await sha(canon(body)) !== e.hash) return broken(i, entries, 'hash', 'its contents were changed after it was written');
|
|
717
|
+
if (!(await check(e.hash, e.sig))) return broken(i, entries, 'signature', 'its signature does not match this key');
|
|
718
|
+
}
|
|
719
|
+
const head = {};
|
|
720
|
+
for (const k of Object.keys(doc.head)) if (k !== 'hash' && k !== 'sig') head[k] = doc.head[k];
|
|
721
|
+
const last = entries.length ? entries[entries.length - 1].hash : ZERO;
|
|
722
|
+
let headOk = (await sha(canon(head))) === doc.head.hash;
|
|
723
|
+
if (headOk) headOk = await check(doc.head.hash, doc.head.sig);
|
|
724
|
+
if (!headOk || head.count !== entries.length || head.last_hash !== last || head.project !== doc.project || head.project_id !== doc.project_id) {
|
|
725
|
+
return { ok: false, index: entries.length, entry: entries.length, code: 'head', reason: 'the signed head does not match these entries. entries were cut from the end or the head was changed', label: 'the end of the record' };
|
|
726
|
+
}
|
|
727
|
+
return { ok: true, entries: entries.length, fingerprint: doc.key_fingerprint };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ---------------------------------------------------------------- html
|
|
731
|
+
|
|
732
|
+
// Self-contained: the exported page embeds this function's source. It runs
|
|
733
|
+
// only after browserVerify said the record is whole. Text goes in through
|
|
734
|
+
// textContent only, never as html.
|
|
735
|
+
function renderReport(doc, s, document, root) {
|
|
736
|
+
const SEV = ['critical', 'high', 'medium', 'low', 'info'];
|
|
737
|
+
const KINDS = ['review', 'recheck', 'agent_check', 'watch', 'guard', 'fix'];
|
|
738
|
+
const WORDS = { review: 'review', recheck: 'recheck', agent_check: 'agent check', watch: 'dependency watch', guard: 'guard summary', fix: 'fix' };
|
|
739
|
+
const add = (parent, tag, text, cls) => {
|
|
740
|
+
const el = document.createElement(tag);
|
|
741
|
+
if (text !== undefined && text !== null) el.textContent = String(text);
|
|
742
|
+
if (cls) el.className = cls;
|
|
743
|
+
parent.appendChild(el);
|
|
744
|
+
return el;
|
|
745
|
+
};
|
|
746
|
+
const date = (d) => (d ? String(d).slice(0, 16).replace('T', ' ') + ' utc' : 'unknown');
|
|
747
|
+
const dur = (ms) => {
|
|
748
|
+
if (ms == null) return 'unknown';
|
|
749
|
+
const h = ms / 3600000;
|
|
750
|
+
if (h < 1) return Math.max(1, Math.round(ms / 60000)) + ' min';
|
|
751
|
+
if (h < 48) return Math.round(h) + ' h';
|
|
752
|
+
return Math.round(h / 24) + ' days';
|
|
753
|
+
};
|
|
754
|
+
const table = (head, rows) => {
|
|
755
|
+
const t = add(root, 'table');
|
|
756
|
+
const tr = add(t, 'tr');
|
|
757
|
+
for (const h of head) add(tr, 'th', h);
|
|
758
|
+
for (const r of rows) { const row = add(t, 'tr'); for (const c of r) add(row, 'td', c); }
|
|
759
|
+
};
|
|
760
|
+
add(root, 'h2', 'project: ' + doc.project);
|
|
761
|
+
add(root, 'p', 'exported ' + date(doc.head.exported_at) + '. ' + doc.entries.length + ' entries.', 'small');
|
|
762
|
+
add(root, 'div', 'public key (ed25519): ' + doc.public_key, 'key');
|
|
763
|
+
add(root, 'div', 'fingerprint: ' + doc.key_fingerprint, 'key');
|
|
764
|
+
add(root, 'p', 'ask the company for its key fingerprint and check it matches. a record signed with a different key proves nothing about them.', 'small');
|
|
765
|
+
add(root, 'h2', 'summary');
|
|
766
|
+
const ran = KINDS.filter((k) => s.checks[k]).map((k) => s.checks[k] + ' ' + WORDS[k] + (s.checks[k] === 1 ? '' : 's')).join(', ') || 'none yet';
|
|
767
|
+
add(root, 'p', 'checks run: ' + ran + (s.backfilled ? '. ' + s.backfilled + ' were added later from past runs, with their original dates' : ''));
|
|
768
|
+
add(root, 'p', 'first check: ' + date(s.first) + '. last check: ' + (s.last ? (WORDS[s.last.kind] || 'check') + ' on ' + date(s.last.at) : 'none'));
|
|
769
|
+
table([''].concat(SEV), [['open'].concat(SEV.map((k) => s.open[k] || 0)), ['fixed'].concat(SEV.map((k) => s.fixed[k] || 0))]);
|
|
770
|
+
if (s.needs_validation) add(root, 'p', s.needs_validation + ' possible problem' + (s.needs_validation === 1 ? '' : 's') + ' wait for a person to check.');
|
|
771
|
+
const rules = Object.keys(s.guard.by_rule).map((r) => r + ' ' + s.guard.by_rule[r]).join(', ');
|
|
772
|
+
add(root, 'p', 'guard: stopped ' + s.guard.blocked + ' agent action' + (s.guard.blocked === 1 ? '' : 's') + (rules ? ' (' + rules + ')' : '') + '.');
|
|
773
|
+
const open = s.findings.filter((f) => f.status === 'open' || f.status === 'needs_validation');
|
|
774
|
+
if (open.length) {
|
|
775
|
+
add(root, 'h2', 'open');
|
|
776
|
+
table(['severity', 'problem', 'where', 'first seen'], open.map((f) => [f.status === 'open' ? f.severity : 'needs a person', f.title || 'problem', f.location || f.id, date(f.first_open)]));
|
|
777
|
+
}
|
|
778
|
+
if (s.fixed_list.length) {
|
|
779
|
+
add(root, 'h2', 'fixed');
|
|
780
|
+
table(['severity', 'problem', 'where', 'stayed open'], s.fixed_list.map((f) => [f.severity, f.title || 'problem', f.location || f.id, dur(f.open_ms)]));
|
|
781
|
+
}
|
|
782
|
+
add(root, 'h2', 'timeline');
|
|
783
|
+
const ul = add(root, 'ul');
|
|
784
|
+
for (const e of doc.entries.slice().reverse()) {
|
|
785
|
+
const li = add(ul, 'li');
|
|
786
|
+
add(li, 'div', date(e.at) + ' · ' + (WORDS[e.kind] || 'check') + (e.backfilled ? ' · added later from a past run' : ''), 'when');
|
|
787
|
+
add(li, 'div', e.summary);
|
|
788
|
+
add(li, 'div', 'entry ' + e.seq + ' · atris ' + e.atris_version + ' · ' + e.exam_version + ' · hash ' + String(e.hash).slice(0, 16), 'meta');
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// The page holds no record text outside the embedded json. The verifier
|
|
793
|
+
// runs first and the report is drawn only when everything checks out.
|
|
794
|
+
function renderHtml(doc) {
|
|
795
|
+
const json = JSON.stringify(doc).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
796
|
+
return `<!doctype html>
|
|
797
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
798
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'">
|
|
799
|
+
<title>atris sentinel security record</title>
|
|
800
|
+
<style>
|
|
801
|
+
body{font:15px/1.5 -apple-system,system-ui,sans-serif;color:#1b1b1b;background:#fafaf8;margin:0;padding:32px;max-width:860px}
|
|
802
|
+
h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:28px 0 8px}
|
|
803
|
+
.key{font:13px ui-monospace,monospace;background:#f0efea;padding:8px 12px;border-radius:6px;word-break:break-all;margin:4px 0}
|
|
804
|
+
#status{margin:16px 0;padding:12px 14px;border-radius:6px;font-weight:600;background:#eee}
|
|
805
|
+
#status.ok{background:#e3f1e5;color:#17532a}#status.bad{background:#f8e1df;color:#7a1d14}
|
|
806
|
+
table{border-collapse:collapse;width:100%;font-size:14px}td,th{border-bottom:1px solid #e3e1da;padding:6px 8px;text-align:left}
|
|
807
|
+
ul{list-style:none;padding:0}li{padding:10px 0;border-bottom:1px solid #e3e1da}
|
|
808
|
+
.when{color:#555;font-weight:600}.meta{font-size:12px;color:#666}.small{font-size:13px;color:#555}
|
|
809
|
+
</style></head><body>
|
|
810
|
+
<h1>atris sentinel security record</h1>
|
|
811
|
+
<p class="small">this page checks the signed record inside it before it shows anything. nothing below the status line is drawn until every entry checks out.</p>
|
|
812
|
+
<div id="status">checking the record in this browser...</div>
|
|
813
|
+
<div id="report"></div>
|
|
814
|
+
<h2>what this page can and cannot prove</h2>
|
|
815
|
+
<p class="small">a verified record came from the holder of the key shown and was not edited, moved or cut since. it does not prove the checks were thorough or that the code is safe. times come from the company's machine.</p>
|
|
816
|
+
<p class="small">someone who rewrites this page's own checking script can still fake what it shows. the check to trust is the atris cli: atris sentinel record verify FILE, or a verifier you get from atris directly.</p>
|
|
817
|
+
<script type="application/json" id="record">${json}</script>
|
|
818
|
+
<script>
|
|
819
|
+
${browserVerify.toString()}
|
|
820
|
+
${summarize.toString()}
|
|
821
|
+
${renderReport.toString()}
|
|
822
|
+
window.recordChecked = (async function () {
|
|
823
|
+
var el = document.getElementById('status');
|
|
824
|
+
var doc;
|
|
825
|
+
try { doc = JSON.parse(document.getElementById('record').textContent); } catch (e) { el.className = 'bad'; el.textContent = 'broken: the record in this page cannot be read'; return; }
|
|
826
|
+
if (!(window.crypto && window.crypto.subtle)) { el.textContent = 'this browser cannot check signatures here. run atris sentinel record verify on the file'; return; }
|
|
827
|
+
var r;
|
|
828
|
+
try { r = await browserVerify(doc, window.crypto.subtle); } catch (e) { el.textContent = 'this browser could not check ed25519 signatures. run atris sentinel record verify on the file'; return; }
|
|
829
|
+
if (!r.ok) { el.className = 'bad'; el.textContent = 'broken at ' + r.label + ': ' + r.reason; return; }
|
|
830
|
+
el.className = 'ok';
|
|
831
|
+
el.textContent = 'verified: all ' + r.entries + ' entries are signed by key ' + r.fingerprint + ' and none were changed, moved or removed';
|
|
832
|
+
renderReport(doc, summarize(doc.entries), document, document.getElementById('report'));
|
|
833
|
+
})();
|
|
834
|
+
</script>
|
|
835
|
+
</body></html>
|
|
836
|
+
`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Read an exported file: json, or the html with the record inside.
|
|
840
|
+
function readExport(file) {
|
|
841
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
842
|
+
const t = text.trim();
|
|
843
|
+
if (t.startsWith('{')) return JSON.parse(t);
|
|
844
|
+
const m = text.match(/<script type="application\/json" id="record">([\s\S]*?)<\/script>/);
|
|
845
|
+
if (!m) throw new Error('no record found in this file');
|
|
846
|
+
return JSON.parse(m[1]);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
module.exports = {
|
|
850
|
+
ZERO_HASH,
|
|
851
|
+
KINDS,
|
|
852
|
+
canonical,
|
|
853
|
+
recordPath,
|
|
854
|
+
keyPath,
|
|
855
|
+
projectName,
|
|
856
|
+
loadKey,
|
|
857
|
+
keyFingerprint,
|
|
858
|
+
append,
|
|
859
|
+
appendSafe,
|
|
860
|
+
warnLine,
|
|
861
|
+
readEntries,
|
|
862
|
+
entryFromRun,
|
|
863
|
+
entryFromAgents,
|
|
864
|
+
entryFromWatch,
|
|
865
|
+
entryFromGuard,
|
|
866
|
+
entryFromFix,
|
|
867
|
+
backfill,
|
|
868
|
+
summarize,
|
|
869
|
+
renderShow,
|
|
870
|
+
buildExport,
|
|
871
|
+
verifyDoc,
|
|
872
|
+
browserVerify,
|
|
873
|
+
renderHtml,
|
|
874
|
+
renderReport,
|
|
875
|
+
projectId,
|
|
876
|
+
readExport,
|
|
877
|
+
};
|