atris 3.82.10 → 3.82.11
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 +1 -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/sentinel-cloud.js +1 -0
- package/lib/sentinel-record.js +877 -0
- package/package.json +1 -1
package/bin/atris.js
CHANGED
|
@@ -638,6 +638,7 @@ function showHelpAll() {
|
|
|
638
638
|
console.log(' exam and self-improvement: sentinel bench, sentinel improve');
|
|
639
639
|
console.log(' fix a confirmed finding on its own branch: sentinel fix <id>');
|
|
640
640
|
console.log(' re-check old findings against today\'s code: sentinel recheck');
|
|
641
|
+
console.log(' signed security record for reviewers: sentinel record, sentinel record export');
|
|
641
642
|
console.log(' voice - Scan binary voice tells or judge reply shape and plainness');
|
|
642
643
|
console.log(' recover - Explain stopped missions; --apply performs safe recovery only');
|
|
643
644
|
console.log(' task - Local agent task plane (atomic claims, TODO import)');
|
|
@@ -724,6 +724,7 @@ async function auditRecheckCommand(argv, deps = {}) {
|
|
|
724
724
|
const fail = audit.shouldFailRun(result.dir, result.records);
|
|
725
725
|
if (opts.json) console.log(JSON.stringify({ ok: !fail, dir: result.dir, meta: result.meta, findings: result.records }, null, 2));
|
|
726
726
|
else console.log(renderRecheckLanding(result));
|
|
727
|
+
addRunToRecord(root, result.dir, tell, deps);
|
|
727
728
|
if (spend && spend.blocked) tell(` stopped at the approved limit of ${spend.max} credits. ${spend.blocked} model call${spend.blocked === 1 ? '' : 's'} did not run. those findings stay open`);
|
|
728
729
|
if (opts.upload && token) {
|
|
729
730
|
try { await uploadAfterRun(result.dir, { token, quiet: opts.json, cloud, root, env: deps.env || process.env }); } catch (e) { tell(` warning: could not save the summary to your atris account: ${e.message}. the re-check itself is done`); }
|
|
@@ -731,6 +732,17 @@ async function auditRecheckCommand(argv, deps = {}) {
|
|
|
731
732
|
return fail ? 1 : 0;
|
|
732
733
|
}
|
|
733
734
|
|
|
735
|
+
// Every finished review and recheck adds a signed entry to the security
|
|
736
|
+
// record. A failure warns and never changes the exit code.
|
|
737
|
+
function addRunToRecord(root, dir, tell, deps = {}) {
|
|
738
|
+
const rec = require('../lib/sentinel-record');
|
|
739
|
+
let res;
|
|
740
|
+
try { res = rec.appendSafe(root, rec.entryFromRun(dir), { recordHome: deps.recordHome, homeDir: deps.homeDir, env: deps.env }); } catch (e) { res = { ok: false, reason: e.message }; }
|
|
741
|
+
const w = rec.warnLine(res);
|
|
742
|
+
if (w) tell(w);
|
|
743
|
+
return res;
|
|
744
|
+
}
|
|
745
|
+
|
|
734
746
|
async function auditCommand(argv, deps = {}) {
|
|
735
747
|
if (argv[0] === 'help' || argv.includes('-h') || argv.includes('--help')) return printAuditHelp();
|
|
736
748
|
const audit = require('../lib/security-audit');
|
|
@@ -752,6 +764,7 @@ async function auditCommand(argv, deps = {}) {
|
|
|
752
764
|
return (argv[0] === 'bench' ? sb.benchCommand : sb.improveCommand)(argv.slice(1), deps);
|
|
753
765
|
}
|
|
754
766
|
if (argv[0] === 'fix') return require('./sentinel-fix').fixCommand(argv.slice(1), deps);
|
|
767
|
+
if (argv[0] === 'record') return require('./sentinel-record').recordCommand(argv.slice(1), deps);
|
|
755
768
|
if (argv[0] === 'upload') return auditUploadCommand(argv.slice(1), deps);
|
|
756
769
|
if (argv[0] === 'recheck') return auditRecheckCommand(argv.slice(1), deps);
|
|
757
770
|
if (argv[0] === 'schedule') return auditScheduleCommand(argv.slice(1), deps);
|
|
@@ -802,6 +815,7 @@ async function auditCommand(argv, deps = {}) {
|
|
|
802
815
|
const fail = !opts.planOnly && audit.shouldFailRun(result.dir, result.records);
|
|
803
816
|
if (opts.json) console.log(JSON.stringify({ ok: !fail, dir: result.dir, meta: result.meta, findings: result.records }, null, 2));
|
|
804
817
|
else console.log(renderAuditLanding(result));
|
|
818
|
+
if (!opts.planOnly) addRunToRecord(root, result.dir, tell, deps);
|
|
805
819
|
if (spend && spend.blocked) tell(` stopped at the approved limit of ${spend.max} credits. ${spend.blocked} model call${spend.blocked === 1 ? '' : 's'} did not run and wait for the next run`);
|
|
806
820
|
if (opts.upload && token && !opts.planOnly) {
|
|
807
821
|
try { await uploadAfterRun(result.dir, { token, quiet: opts.json, cloud, root, env: deps.env || process.env }); } catch (e) { tell(` warning: could not save the summary to your atris account: ${e.message}. the review itself is done`); }
|
|
@@ -872,6 +886,15 @@ function printAuditHelp() {
|
|
|
872
886
|
atris sentinel improve [--engine claude] one engine proposes a change, the exam decides if it stays
|
|
873
887
|
atris sentinel improve --schedule run the improve loop nightly
|
|
874
888
|
|
|
889
|
+
the signed security record (hand it to a customer's security reviewer):
|
|
890
|
+
|
|
891
|
+
atris sentinel record [path] what was checked, what is open and fixed, how long fixes took
|
|
892
|
+
atris sentinel record key the public key and fingerprint reviewers compare against
|
|
893
|
+
atris sentinel record export --out record.html
|
|
894
|
+
one file that checks its own signatures in the browser
|
|
895
|
+
atris sentinel record verify FILE check an exported record offline
|
|
896
|
+
atris sentinel record backfill add past reviews, marked as added later
|
|
897
|
+
|
|
875
898
|
turning a confirmed finding into a fix:
|
|
876
899
|
|
|
877
900
|
atris sentinel fix list confirmed findings you can fix
|
package/commands/sentinel-fix.js
CHANGED
|
@@ -222,6 +222,14 @@ async function fixCommand(argv, deps = {}) {
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
{
|
|
226
|
+
const rec = require('../lib/sentinel-record');
|
|
227
|
+
const o = { recordHome: deps.recordHome, homeDir: deps.homeDir, env: deps.env, record: deps.record };
|
|
228
|
+
let r;
|
|
229
|
+
try { r = rec.appendSafe(root, results.map((x) => rec.entryFromFix(x.record, x.res)), o); } catch (e) { r = { ok: false, reason: e.message }; }
|
|
230
|
+
const w = rec.warnLine(r);
|
|
231
|
+
if (w) console.log(w);
|
|
232
|
+
}
|
|
225
233
|
const done = results.filter((x) => x.res.ok);
|
|
226
234
|
if (done.length) {
|
|
227
235
|
const events = require('../lib/sentinel-events');
|
|
@@ -78,6 +78,22 @@ function startUpdateNotice(argv = [], { env = process.env, check, timeoutMs = 15
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// ---------------------------------------------------------------- record
|
|
82
|
+
|
|
83
|
+
// Each check adds a signed entry to the security record
|
|
84
|
+
// (lib/sentinel-record.js). A failure is a warning line, never an exit code.
|
|
85
|
+
function addToRecord(root, build, deps = {}) {
|
|
86
|
+
const rec = require('../lib/sentinel-record');
|
|
87
|
+
const o = { recordHome: deps.recordHome, homeDir: deps.homeDir, env: deps.env, record: deps.record };
|
|
88
|
+
if (deps.record === false) return { ok: true, skipped: true };
|
|
89
|
+
try { return rec.appendSafe(root, build(rec, o), o); } catch (e) { return { ok: false, reason: String(e.message || e).split('\n')[0] }; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function recordWarn(res, json) {
|
|
93
|
+
const w = require('../lib/sentinel-record').warnLine(res);
|
|
94
|
+
if (w) (json ? console.error : console.log)(w);
|
|
95
|
+
}
|
|
96
|
+
|
|
81
97
|
// ---------------------------------------------------------------- agents
|
|
82
98
|
|
|
83
99
|
async function runAgents(root, { home = false, upload = true, deps = {} } = {}) {
|
|
@@ -90,7 +106,8 @@ async function runAgents(root, { home = false, upload = true, deps = {} } = {})
|
|
|
90
106
|
try {
|
|
91
107
|
res = await events.reportFindings(root, 'agent_check', evs, { token: upload ? tokenFor(deps) : '', base: deps.base, env: deps.env || process.env, upload });
|
|
92
108
|
} catch (e) { res = { ok: false, reason: e.message }; }
|
|
93
|
-
|
|
109
|
+
const record = addToRecord(root, (rec, o) => rec.entryFromAgents(root, result, o), deps);
|
|
110
|
+
return { result, events: evs, upload: res, record };
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
async function agentsCommand(argv, deps = {}) {
|
|
@@ -98,7 +115,8 @@ async function agentsCommand(argv, deps = {}) {
|
|
|
98
115
|
try { opts = parseFlags(argv, ['home', 'json', 'no-upload']); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
99
116
|
let root;
|
|
100
117
|
try { root = resolveRoot(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
101
|
-
const { result, upload } = await runAgents(root, { home: Boolean(opts.home), upload: !opts.noUpload, deps });
|
|
118
|
+
const { result, upload, record } = await runAgents(root, { home: Boolean(opts.home), upload: !opts.noUpload, deps });
|
|
119
|
+
recordWarn(record, opts.json);
|
|
102
120
|
if (opts.json) {
|
|
103
121
|
console.log(JSON.stringify({ ok: result.grade !== 'fail', grade: result.grade, areas: result.areas, files: result.files, findings: result.findings, upload }, null, 2));
|
|
104
122
|
} else {
|
|
@@ -153,7 +171,8 @@ async function runWatchCheck(root, { upload = true, deps = {} } = {}) {
|
|
|
153
171
|
res.heldBranch = why.branch;
|
|
154
172
|
} catch (e) { res = { ok: false, reason: e.message }; }
|
|
155
173
|
}
|
|
156
|
-
|
|
174
|
+
const record = addToRecord(root, (rec, o) => rec.entryFromWatch(root, result, { ...o, branch }), deps);
|
|
175
|
+
return { result, events: evs, upload: res, branch, record };
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
function heldLines(upload, branch) {
|
|
@@ -186,7 +205,8 @@ async function watchCommand(argv, deps = {}) {
|
|
|
186
205
|
let root;
|
|
187
206
|
try { root = resolveRoot(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
188
207
|
const watch = require('../lib/sentinel-watch');
|
|
189
|
-
const { result, upload, branch } = await runWatchCheck(root, { upload: !opts.noUpload, deps });
|
|
208
|
+
const { result, upload, branch, record } = await runWatchCheck(root, { upload: !opts.noUpload, deps });
|
|
209
|
+
recordWarn(record, opts.json);
|
|
190
210
|
let fix = null;
|
|
191
211
|
if (opts.fix && !result.offline && result.affected.length) {
|
|
192
212
|
try { fix = watch.applyFix(root, result, { run: deps.run, allowMajor: Boolean(opts.allowMajor) }); } catch (e) { fix = { ok: false, reason: String(e.message || e).split('\n')[0] }; }
|
|
@@ -279,6 +299,7 @@ async function guardCommand(argv, deps = {}) {
|
|
|
279
299
|
const blocks = inst.recentBlocks({ env });
|
|
280
300
|
const events = require('../lib/sentinel-events');
|
|
281
301
|
const waiting = events.queueSize(env);
|
|
302
|
+
recordWarn(addToRecord(root, (rec, o) => rec.entryFromGuard(root, o), { ...deps, env }), opts.json);
|
|
282
303
|
if (opts.json) { console.log(JSON.stringify({ ...st, blocked_today: blocks.length, waiting_to_send: waiting }, null, 2)); return 0; }
|
|
283
304
|
console.log('');
|
|
284
305
|
if (st.installed) console.log(` the sentinel guard is on for ${where}`);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris sentinel record: the signed security record a reviewer can check.
|
|
4
|
+
//
|
|
5
|
+
// atris sentinel record [show] [path] plain summary
|
|
6
|
+
// atris sentinel record key [path] public key and fingerprint
|
|
7
|
+
// atris sentinel record export [path] --out FILE [--json]
|
|
8
|
+
// atris sentinel record verify FILE offline check of an export
|
|
9
|
+
// atris sentinel record backfill [path] add past review runs, marked as added later
|
|
10
|
+
//
|
|
11
|
+
// The guarantee and its limits sit at the top of lib/sentinel-record.js.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
function help() {
|
|
17
|
+
console.log(`
|
|
18
|
+
atris sentinel record, a signed security record you can hand to a reviewer
|
|
19
|
+
|
|
20
|
+
atris sentinel record [path] what sentinel checked, what is open and fixed
|
|
21
|
+
atris sentinel record key [path] this project's public key and its fingerprint
|
|
22
|
+
atris sentinel record export [path] --out record.html
|
|
23
|
+
one html file a reviewer opens and checks in the browser
|
|
24
|
+
atris sentinel record export [path] --out record.json --json
|
|
25
|
+
atris sentinel record verify FILE check an exported record offline
|
|
26
|
+
atris sentinel record backfill [path] add past reviews, marked as added later with their dates
|
|
27
|
+
|
|
28
|
+
every review, recheck, agent check, watch, guard status and fix adds an
|
|
29
|
+
entry. each entry holds the hash of the one before it and is signed with
|
|
30
|
+
a key kept in ~/.atris. an edited, moved or removed entry shows.
|
|
31
|
+
|
|
32
|
+
what it proves: the record came from this key and was not changed.
|
|
33
|
+
what it does not prove: that the checks were thorough or the code is safe.
|
|
34
|
+
entries hold plain sentences, counts, file:line places and short ids only.
|
|
35
|
+
`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parse(argv) {
|
|
40
|
+
const opts = { paths: [] };
|
|
41
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (a === '--out') { opts.out = argv[++i]; if (!opts.out) throw new Error('--out needs a file'); }
|
|
44
|
+
else if (a === '--json') opts.json = true;
|
|
45
|
+
else if (a.startsWith('-')) throw new Error(`unknown flag: ${a}`);
|
|
46
|
+
else opts.paths.push(a);
|
|
47
|
+
}
|
|
48
|
+
return opts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rootOf(opts, deps) {
|
|
52
|
+
const root = path.resolve(opts.paths[0] || deps.root || process.cwd());
|
|
53
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`${root} is not a folder`);
|
|
54
|
+
return root;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function libOpts(deps) {
|
|
58
|
+
const o = {};
|
|
59
|
+
if (deps.recordHome) o.recordHome = deps.recordHome;
|
|
60
|
+
if (deps.homeDir) o.homeDir = deps.homeDir;
|
|
61
|
+
if (deps.env) o.env = deps.env;
|
|
62
|
+
if (deps.now) o.now = deps.now;
|
|
63
|
+
return o;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function recordCommand(argv = [], deps = {}) {
|
|
67
|
+
const rec = require('../lib/sentinel-record');
|
|
68
|
+
const known = ['show', 'key', 'export', 'verify', 'backfill', 'help'];
|
|
69
|
+
const sub = known.includes(argv[0]) ? argv[0] : 'show';
|
|
70
|
+
const rest = known.includes(argv[0]) ? argv.slice(1) : argv;
|
|
71
|
+
if (sub === 'help' || rest.includes('--help') || rest.includes('-h')) return help();
|
|
72
|
+
let opts;
|
|
73
|
+
try { opts = parse(rest); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
74
|
+
const lo = libOpts(deps);
|
|
75
|
+
|
|
76
|
+
if (sub === 'verify') {
|
|
77
|
+
const file = opts.paths[0];
|
|
78
|
+
if (!file) { console.error(' name the exported file: atris sentinel record verify FILE'); return 2; }
|
|
79
|
+
let doc;
|
|
80
|
+
try { doc = rec.readExport(path.resolve(file)); } catch (e) { console.error(` could not read ${file}: ${e.message}`); return 2; }
|
|
81
|
+
const r = rec.verifyDoc(doc);
|
|
82
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return r.ok ? 0 : 1; }
|
|
83
|
+
if (r.ok) {
|
|
84
|
+
console.log(` verified: all ${r.entries} entries for ${doc.project} are signed by key ${r.fingerprint}`);
|
|
85
|
+
console.log(' none were changed, moved or removed. compare the fingerprint with the one the company gave you');
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
console.log(` broken at ${r.label}: ${r.reason}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let root;
|
|
93
|
+
try { root = rootOf(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
94
|
+
const project = rec.projectName(root);
|
|
95
|
+
const id = rec.projectId(root);
|
|
96
|
+
|
|
97
|
+
if (sub === 'key') {
|
|
98
|
+
let key;
|
|
99
|
+
try { key = rec.loadKey(id, { ...lo, name: project }); } catch (e) { console.error(` could not open the record key: ${e.message}`); return 1; }
|
|
100
|
+
if (opts.json) { console.log(JSON.stringify({ project, project_id: id, public_key: key.publicKey, fingerprint: key.fingerprint }, null, 2)); return 0; }
|
|
101
|
+
console.log(`\n record key for ${project}${key.created ? ' (made just now)' : ''}`);
|
|
102
|
+
console.log(` public key: ${key.publicKey}`);
|
|
103
|
+
console.log(` fingerprint: ${key.fingerprint}`);
|
|
104
|
+
console.log(' give the fingerprint to reviewers through a channel they trust. the private key stays in ~/.atris\n');
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (sub === 'backfill') {
|
|
109
|
+
let res;
|
|
110
|
+
try { res = rec.backfill(root, lo); } catch (e) { console.error(` could not backfill the record: ${e.message}`); return 1; }
|
|
111
|
+
console.log(` added ${res.added.length} past run${res.added.length === 1 ? '' : 's'} to the record, each marked as added later with its original date`);
|
|
112
|
+
if (res.skipped.length) console.log(` ${res.skipped.length} already in the record or unreadable`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (sub === 'export') {
|
|
117
|
+
if (!opts.out) { console.error(' name the output file: atris sentinel record export --out record.html'); return 2; }
|
|
118
|
+
let doc;
|
|
119
|
+
try { doc = rec.buildExport(root, lo); } catch (e) { console.error(` could not export the record: ${e.message}`); return 1; }
|
|
120
|
+
const out = path.resolve(opts.out);
|
|
121
|
+
const body = opts.json ? `${JSON.stringify(doc, null, 2)}\n` : rec.renderHtml(doc);
|
|
122
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
123
|
+
fs.writeFileSync(out, body);
|
|
124
|
+
console.log(` wrote ${doc.entries.length} signed entries to ${out}`);
|
|
125
|
+
console.log(` key fingerprint: ${doc.key_fingerprint}`);
|
|
126
|
+
console.log(opts.json ? ' check it with: atris sentinel record verify FILE' : ' a reviewer opens it in a browser. it checks itself and shows verified or broken');
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// show
|
|
131
|
+
let entries;
|
|
132
|
+
try { entries = rec.readEntries(rec.recordPath(root, lo)); } catch (e) { console.error(` could not read the record: ${e.message}`); return 1; }
|
|
133
|
+
const key = rec.loadKey(id, { ...lo, create: false });
|
|
134
|
+
if (opts.json) { console.log(JSON.stringify({ project, project_id: id, key_fingerprint: key ? key.fingerprint : null, ...rec.summarize(entries) }, null, 2)); return 0; }
|
|
135
|
+
console.log(rec.renderShow(project, entries, key));
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { recordCommand };
|
package/lib/sentinel-cloud.js
CHANGED
|
@@ -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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "3.82.
|
|
3
|
+
"version": "3.82.11",
|
|
4
4
|
"description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
|
|
5
5
|
"mcpName": "io.github.atrislabs/atris",
|
|
6
6
|
"main": "bin/atris.js",
|