atris 3.82.2 → 3.82.4

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.
@@ -0,0 +1,529 @@
1
+ 'use strict';
2
+
3
+ // atris sentinel watch: known attacks and security holes in the packages a
4
+ // project uses. Reads the lockfiles, asks the free OSV database
5
+ // (https://api.osv.dev, no key) which exact versions are affected, and
6
+ // reports each affected package with the version that fixes it.
7
+ //
8
+ // parse package-lock.json, pnpm-lock.yaml, yarn.lock, requirements.txt,
9
+ // poetry.lock, go.sum, Gemfile.lock (exact versions only)
10
+ // query POST /v1/querybatch returns advisory ids per package, then
11
+ // GET /v1/vulns/<id> gives the affected ranges and fixed versions
12
+ // fix --fix updates packages on a new git branch, commits there and
13
+ // never pushes. Never commits on main or master.
14
+ //
15
+ // Only package names, versions and advisory ids go to OSV. No source code.
16
+ // A network failure is a warning in the result, never a throw.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const events = require('./sentinel-events');
21
+
22
+ const OSV_BASE = 'https://api.osv.dev';
23
+ const BATCH_SIZE = 1000;
24
+ const MAX_VULN_DETAILS = 400;
25
+
26
+ // ---------------------------------------------------------------- parse
27
+
28
+ function readMaybe(file) {
29
+ try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
30
+ }
31
+
32
+ function parsePackageLock(text) {
33
+ const out = [];
34
+ let json;
35
+ try { json = JSON.parse(text); } catch { return out; }
36
+ if (json && json.packages && typeof json.packages === 'object') {
37
+ for (const [key, v] of Object.entries(json.packages)) {
38
+ if (!key || !v || v.link || !v.version) continue;
39
+ const at = key.lastIndexOf('node_modules/');
40
+ if (at === -1) continue;
41
+ out.push({ name: v.name || key.slice(at + 'node_modules/'.length), version: String(v.version), ecosystem: 'npm', direct: key.split('node_modules/').length === 2, dev: Boolean(v.dev) });
42
+ }
43
+ return out;
44
+ }
45
+ const walkDeps = (deps, depth) => {
46
+ for (const [name, v] of Object.entries(deps || {})) {
47
+ if (v && v.version && !/^(?:file|link|git)/.test(v.version)) out.push({ name, version: String(v.version), ecosystem: 'npm', direct: depth === 0, dev: Boolean(v.dev) });
48
+ if (v && v.dependencies) walkDeps(v.dependencies, depth + 1);
49
+ }
50
+ };
51
+ walkDeps(json && json.dependencies, 0);
52
+ return out;
53
+ }
54
+
55
+ // pnpm keys: "/name/1.2.3:" (v5), "/name@1.2.3:" (v6), "name@1.2.3:" (v9),
56
+ // each with an optional "(peer@x)" suffix.
57
+ function parsePnpmLock(text) {
58
+ const out = [];
59
+ let inPackages = false;
60
+ for (const line of text.split('\n')) {
61
+ if (/^\S/.test(line)) { inPackages = /^(?:packages|snapshots):\s*$/.test(line); continue; }
62
+ if (!inPackages) continue;
63
+ const m = line.match(/^ {2}['"]?\/?((?:@[^/@\s]+\/)?[^/@\s(]+)[@/]([0-9][^:('"\s]*)(?:\([^:]*\))?['"]?:\s*$/);
64
+ if (!m) continue;
65
+ out.push({ name: m[1], version: m[2], ecosystem: 'npm' });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // yarn classic: `"name@^1.0.0", name@^1.1.0:` then ` version "1.2.3"`.
71
+ // yarn berry: `"name@npm:^1.0.0":` then ` version: 1.2.3`.
72
+ function parseYarnLock(text) {
73
+ const out = [];
74
+ let current = null;
75
+ for (const line of text.split('\n')) {
76
+ if (/^\S/.test(line) && line.trim().endsWith(':') && !line.startsWith('#') && !line.startsWith('__metadata')) {
77
+ const first = line.slice(0, -1).split(',')[0].trim().replace(/^"|"$/g, '');
78
+ const at = first.lastIndexOf('@');
79
+ current = at > 0 ? first.slice(0, at) : null;
80
+ if (current && /@(?:workspace|patch|link|portal|file):/.test(first)) current = null;
81
+ continue;
82
+ }
83
+ const v = line.match(/^ {2}version:?\s+"?([^"\s]+)"?\s*$/);
84
+ if (v && current) { out.push({ name: current, version: v[1], ecosystem: 'npm' }); current = null; }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ function parseRequirements(text) {
90
+ const out = [];
91
+ for (const raw of text.split('\n')) {
92
+ const line = raw.replace(/\s+#.*$/, '').trim();
93
+ if (!line || line.startsWith('#') || line.startsWith('-')) continue;
94
+ const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([0-9][A-Za-z0-9.!+-]*)\s*(?:;.*)?$/);
95
+ if (m) out.push({ name: m[1], version: m[2], ecosystem: 'PyPI', direct: true });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function parsePoetryLock(text) {
101
+ const out = [];
102
+ let cur = null;
103
+ for (const line of text.split('\n')) {
104
+ if (/^\[\[package\]\]\s*$/.test(line)) { if (cur && cur.name && cur.version) out.push(cur); cur = { ecosystem: 'PyPI' }; continue; }
105
+ if (/^\[/.test(line)) { if (cur && cur.name && cur.version) out.push(cur); cur = null; continue; }
106
+ if (!cur) continue;
107
+ const m = line.match(/^(name|version)\s*=\s*"([^"]+)"/);
108
+ if (m) cur[m[1]] = m[2];
109
+ }
110
+ if (cur && cur.name && cur.version) out.push(cur);
111
+ return out;
112
+ }
113
+
114
+ function parseGoSum(text) {
115
+ const out = [];
116
+ for (const line of text.split('\n')) {
117
+ const m = line.trim().match(/^(\S+)\s+(v[^\s/]+)(\/go\.mod)?\s+h1:/);
118
+ if (!m || m[3]) continue;
119
+ out.push({ name: m[1], version: m[2].replace(/^v/, '').replace(/\+incompatible$/, ''), ecosystem: 'Go' });
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function parseGemfileLock(text) {
125
+ const out = [];
126
+ let inSpecs = false;
127
+ for (const line of text.split('\n')) {
128
+ if (/^\S/.test(line)) { inSpecs = false; continue; }
129
+ if (/^ {2}specs:\s*$/.test(line)) { inSpecs = true; continue; }
130
+ if (!inSpecs) continue;
131
+ const m = line.match(/^ {4}([A-Za-z0-9._-]+) \(([0-9][^)\s-]*)(?:-[^)]*)?\)\s*$/);
132
+ if (m) out.push({ name: m[1], version: m[2], ecosystem: 'RubyGems' });
133
+ }
134
+ return out;
135
+ }
136
+
137
+ const LOCKFILES = [
138
+ { file: 'package-lock.json', parse: parsePackageLock },
139
+ { file: 'npm-shrinkwrap.json', parse: parsePackageLock },
140
+ { file: 'pnpm-lock.yaml', parse: parsePnpmLock },
141
+ { file: 'yarn.lock', parse: parseYarnLock },
142
+ { file: 'requirements.txt', parse: parseRequirements },
143
+ { file: 'poetry.lock', parse: parsePoetryLock },
144
+ { file: 'go.sum', parse: parseGoSum },
145
+ { file: 'Gemfile.lock', parse: parseGemfileLock },
146
+ ];
147
+
148
+ // Only names and versions of a known shape go to OSV. A lockfile entry
149
+ // holding a URL, a path, a git address or anything else is skipped and
150
+ // counted, never sent.
151
+ const VERSION_SHAPE = {
152
+ npm: /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/,
153
+ Go: /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/,
154
+ RubyGems: /^\d+(?:\.[0-9A-Za-z]+){0,5}$/,
155
+ // PEP 440: [N!]N(.N)*[{a|b|rc}N][.postN][.devN][+local]
156
+ PyPI: /^(?:\d+!)?\d+(?:\.\d+)*(?:[-_.]?(?:a|b|c|rc|alpha|beta|pre|preview)\d*)?(?:[-_.]?(?:post|rev|r)\d*)?(?:[-_.]?dev\d*)?(?:\+[a-z0-9]+(?:[-_.][a-z0-9]+)*)?$/i,
157
+ };
158
+ const NAME_SHAPE = {
159
+ npm: /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9._~-][a-z0-9._~-]*$/i,
160
+ Go: /^[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9._~-]+)*$/,
161
+ RubyGems: /^[A-Za-z0-9][A-Za-z0-9._-]*$/,
162
+ PyPI: /^[A-Za-z0-9][A-Za-z0-9._-]*$/,
163
+ };
164
+
165
+ function looksReal(p) {
166
+ const v = VERSION_SHAPE[p.ecosystem];
167
+ const n = NAME_SHAPE[p.ecosystem];
168
+ return Boolean(v && n && v.test(String(p.version)) && n.test(String(p.name)) && String(p.name).length <= 214 && String(p.version).length <= 64);
169
+ }
170
+
171
+ function readLockfiles(root) {
172
+ const found = [];
173
+ const seen = new Set();
174
+ const packages = [];
175
+ let skipped = 0;
176
+ for (const lf of LOCKFILES) {
177
+ const file = path.join(root, lf.file);
178
+ const text = readMaybe(file);
179
+ if (text == null) continue;
180
+ const list = lf.parse(text);
181
+ found.push({ file: lf.file, packages: list.length });
182
+ for (const p of list) {
183
+ if (!looksReal(p)) { skipped += 1; continue; }
184
+ const key = `${p.ecosystem}\u0000${p.name}\u0000${p.version}`;
185
+ if (seen.has(key)) continue;
186
+ seen.add(key);
187
+ packages.push({ ...p, lockfile: lf.file });
188
+ }
189
+ }
190
+ return { lockfiles: found, packages, skipped };
191
+ }
192
+
193
+ // ---------------------------------------------------------------- versions
194
+
195
+ function parts(v) {
196
+ return String(v || '').replace(/^v/, '').split(/[.+-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
197
+ }
198
+
199
+ // Compare two versions: numeric parts by number, a prerelease tag sorts
200
+ // before the release (1.0.0-beta < 1.0.0).
201
+ function compareVersions(a, b) {
202
+ const [ma, pa] = String(a).replace(/^v/, '').split(/-(.*)/s);
203
+ const [mb, pb] = String(b).replace(/^v/, '').split(/-(.*)/s);
204
+ const xa = parts(ma);
205
+ const xb = parts(mb);
206
+ for (let i = 0; i < Math.max(xa.length, xb.length); i++) {
207
+ const p = xa[i] === undefined ? 0 : xa[i];
208
+ const q = xb[i] === undefined ? 0 : xb[i];
209
+ if (p === q) continue;
210
+ if (typeof p === 'number' && typeof q === 'number') return p < q ? -1 : 1;
211
+ return String(p) < String(q) ? -1 : 1;
212
+ }
213
+ if (pa && !pb) return -1;
214
+ if (!pa && pb) return 1;
215
+ if (pa && pb) return pa < pb ? -1 : (pa > pb ? 1 : 0);
216
+ return 0;
217
+ }
218
+
219
+ // The lowest fixed version above the version in use, from the advisory's
220
+ // ranges for this package. null when no fix is published.
221
+ function fixedVersionFor(vuln, pkg) {
222
+ const fixes = [];
223
+ const norm = (n) => (pkg.ecosystem === 'PyPI' ? String(n).toLowerCase().replace(/[-_.]+/g, '-') : String(n));
224
+ for (const aff of (vuln && vuln.affected) || []) {
225
+ const p = aff.package || {};
226
+ if (p.ecosystem && p.ecosystem !== pkg.ecosystem) continue;
227
+ if (p.name && norm(p.name) !== norm(pkg.name)) continue;
228
+ for (const range of aff.ranges || []) {
229
+ if (range.type === 'GIT') continue;
230
+ let introduced = '0';
231
+ for (const ev of range.events || []) {
232
+ if (ev.introduced !== undefined) introduced = ev.introduced;
233
+ if (ev.fixed !== undefined) {
234
+ const inRange = (introduced === '0' || compareVersions(pkg.version, introduced) >= 0) && compareVersions(pkg.version, ev.fixed) < 0;
235
+ if (inRange) fixes.push(ev.fixed);
236
+ }
237
+ }
238
+ }
239
+ }
240
+ if (!fixes.length) return null;
241
+ return fixes.sort(compareVersions)[0];
242
+ }
243
+
244
+ const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
245
+
246
+ function severityOf(vuln) {
247
+ if (/^MAL-/.test(String(vuln.id || ''))) return 'critical';
248
+ const label = String((vuln.database_specific && vuln.database_specific.severity) || '').toLowerCase();
249
+ if (label === 'critical' || label === 'high' || label === 'low') return label;
250
+ if (label === 'moderate' || label === 'medium') return 'medium';
251
+ for (const aff of vuln.affected || []) {
252
+ const s = String((aff.ecosystem_specific && aff.ecosystem_specific.severity) || (aff.database_specific && aff.database_specific.severity) || '').toLowerCase();
253
+ if (['critical', 'high', 'low'].includes(s)) return s;
254
+ if (s === 'moderate' || s === 'medium') return 'medium';
255
+ }
256
+ // CVSS vector with no label: read the base score band from the vector's
257
+ // impact letters, roughly. Unknown stays medium.
258
+ return 'medium';
259
+ }
260
+
261
+ // ---------------------------------------------------------------- osv
262
+
263
+ async function osvBatch(packages, { base = OSV_BASE, request, timeoutMs = 30000 } = {}) {
264
+ const results = new Array(packages.length).fill(null).map(() => []);
265
+ for (let start = 0; start < packages.length; start += BATCH_SIZE) {
266
+ const slice = packages.slice(start, start + BATCH_SIZE);
267
+ let queries = slice.map((p) => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version }));
268
+ let idx = slice.map((_, i) => start + i);
269
+ // Pages: a query with more results carries next_page_token.
270
+ for (let round = 0; round < 5 && queries.length; round++) {
271
+ const res = await request('POST', `${base}/v1/querybatch`, { body: { queries }, timeoutMs });
272
+ if (res.status < 200 || res.status >= 300 || !res.json || !Array.isArray(res.json.results)) throw new Error(`osv answered ${res.status}`);
273
+ const nextQ = [];
274
+ const nextIdx = [];
275
+ res.json.results.forEach((r, k) => {
276
+ for (const v of (r && r.vulns) || []) if (v && v.id) results[idx[k]].push(v.id);
277
+ if (r && r.next_page_token) { nextQ.push({ ...queries[k], page_token: r.next_page_token }); nextIdx.push(idx[k]); }
278
+ });
279
+ queries = nextQ;
280
+ idx = nextIdx;
281
+ }
282
+ }
283
+ return results;
284
+ }
285
+
286
+ async function osvDetails(ids, { base = OSV_BASE, request, timeoutMs = 20000, concurrency = 8 } = {}) {
287
+ const out = {};
288
+ const list = [...new Set(ids)].slice(0, MAX_VULN_DETAILS);
289
+ let next = 0;
290
+ let failed = 0;
291
+ const work = async () => {
292
+ while (next < list.length) {
293
+ const id = list[next++];
294
+ try {
295
+ const res = await request('GET', `${base}/v1/vulns/${encodeURIComponent(id)}`, { timeoutMs });
296
+ if (res.status >= 200 && res.status < 300 && res.json) out[id] = res.json;
297
+ else failed += 1;
298
+ } catch { failed += 1; }
299
+ }
300
+ };
301
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, list.length)) }, work));
302
+ return { details: out, failed, skipped: Math.max(0, new Set(ids).size - list.length) };
303
+ }
304
+
305
+ function defaultRequest() {
306
+ return require('./sentinel-cloud').request;
307
+ }
308
+
309
+ async function runWatch(root, { base = process.env.ATRIS_OSV_BASE || OSV_BASE, request = defaultRequest() } = {}) {
310
+ const { lockfiles, packages, skipped } = readLockfiles(root);
311
+ const result = { root: path.resolve(root), lockfiles, packages: packages.length, skipped, affected: [], warnings: [] };
312
+ if (skipped) result.warnings.push(`skipped ${skipped} lockfile entr${skipped === 1 ? 'y' : 'ies'} that did not look like a package version. they were not sent`);
313
+ if (!packages.length) return result;
314
+ let ids;
315
+ try {
316
+ ids = await osvBatch(packages, { base, request });
317
+ } catch (e) {
318
+ result.warnings.push(`could not reach the osv vulnerability database: ${e.message}. nothing was checked this time`);
319
+ result.offline = true;
320
+ return result;
321
+ }
322
+ const all = ids.flat();
323
+ const { details, failed, skipped: unread } = await osvDetails(all, { base, request });
324
+ if (failed) result.warnings.push(`${failed} advisor${failed === 1 ? 'y' : 'ies'} could not be read from osv. the list below may be short`);
325
+ if (unread) result.warnings.push(`only the first ${MAX_VULN_DETAILS} advisories were read`);
326
+ packages.forEach((pkg, i) => {
327
+ const vids = [...new Set(ids[i])];
328
+ if (!vids.length) return;
329
+ const vulns = vids.map((id) => details[id] || { id });
330
+ const fixes = vulns.map((v) => (v.affected ? fixedVersionFor(v, pkg) : null)).filter(Boolean);
331
+ // One version that fixes every advisory with a fix: the highest of the
332
+ // lowest fixes.
333
+ const fixed = fixes.length ? fixes.sort(compareVersions)[fixes.length - 1] : null;
334
+ const malicious = vids.some((id) => /^MAL-/.test(id));
335
+ const severity = vulns.map(severityOf).sort((a, b) => SEV_RANK[a] - SEV_RANK[b])[0] || 'medium';
336
+ result.affected.push({ ...pkg, advisories: vids, fixed, malicious, severity, unfixed: fixes.length < vids.length });
337
+ });
338
+ result.affected.sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.name.localeCompare(b.name));
339
+ return result;
340
+ }
341
+
342
+ // ---------------------------------------------------------------- events
343
+
344
+ function threatTitle(a) {
345
+ const kind = events.slotOk('package', a.name) ? 'package' : 'module';
346
+ const slots = { pkg: [kind, a.name, 'one'], fixed: ['version', a.fixed, 'a newer version'] };
347
+ const subject = events.slotOk(kind, a.name) ? 'the {pkg} package you use' : 'a package you use';
348
+ if (a.malicious) return events.fillPlain(`A published attack affects ${subject}. Remove it or move to a version that is not affected.`, slots);
349
+ if (a.fixed) return events.fillPlain(`A known security hole affects ${subject}. Version {fixed} fixes it.`, slots);
350
+ return events.fillPlain(`A known security hole affects ${subject}. No fixed version is published yet.`, slots);
351
+ }
352
+
353
+ function threatDetail(a) {
354
+ const ids = a.advisories.filter((id) => events.slotOk('advisory', id)).slice(0, 5);
355
+ const one = a.advisories.length === 1;
356
+ const slots = { n: ['count', String(a.advisories.length), 'some'], fixed: ['version', a.fixed, 'a newer version'], current: ['version', a.version, 'the current version'] };
357
+ const found = one ? 'Found in one published advisory against version {current}.' : 'Found in {n} published advisories against version {current}.';
358
+ const head = a.fixed
359
+ ? events.fillPlain(`${found} Update to version {fixed} or later and run your tests.`, slots, 1000)
360
+ : events.fillPlain(`${found} Watch for a fix or replace the package.`, slots, 1000);
361
+ const tail = ids.length ? ` Advisories: ${ids.join(' ')}.` : '';
362
+ return `${head}${tail}`.slice(0, 1000);
363
+ }
364
+
365
+ function toEvents(result, { repo, now = new Date() } = {}) {
366
+ const name = repo || events.repoName(result.root);
367
+ return result.affected.map((a) => events.makeEvent({
368
+ kind: 'threat',
369
+ repo: name,
370
+ severity: a.severity,
371
+ title: threatTitle(a),
372
+ detail: threatDetail(a),
373
+ status: 'open',
374
+ // Per package and version: updating it changes the version, so the old
375
+ // finding resolves on the next run.
376
+ fingerprint: events.fingerprint('threat', a.ecosystem, a.name, a.version),
377
+ occurredAt: now,
378
+ }));
379
+ }
380
+
381
+ function renderReport(result) {
382
+ const L = [''];
383
+ const files = result.lockfiles.map((l) => l.file).join(', ');
384
+ if (!result.lockfiles.length) {
385
+ L.push(' sentinel watch: no lockfile found here. there is nothing to check');
386
+ L.push(' it reads package-lock.json, pnpm-lock.yaml, yarn.lock, requirements.txt, poetry.lock, go.sum and Gemfile.lock');
387
+ L.push('');
388
+ return L.join('\n');
389
+ }
390
+ L.push(` sentinel watch: checked ${result.packages} package versions from ${files} against the osv database`);
391
+ for (const w of result.warnings) L.push(` warning: ${w}`);
392
+ L.push('');
393
+ if (result.offline) return L.join('\n');
394
+ if (!result.affected.length) {
395
+ L.push(' no known attacks or security holes affect the versions you use');
396
+ L.push('');
397
+ return L.join('\n');
398
+ }
399
+ for (const a of result.affected.slice(0, 80)) {
400
+ const what = a.malicious ? 'published attack' : 'security hole';
401
+ const fix = a.fixed ? `fixed in ${a.fixed}` : 'no fix yet';
402
+ L.push(` ${a.severity.padEnd(8)} ${a.name} ${a.version} (${a.ecosystem}): ${what}. ${fix}`);
403
+ L.push(` ${a.advisories.slice(0, 4).join(' ')}${a.advisories.length > 4 ? ` and ${a.advisories.length - 4} more` : ''}`);
404
+ }
405
+ if (result.affected.length > 80) L.push(` and ${result.affected.length - 80} more. run with --json to see all`);
406
+ const fixable = result.affected.filter((a) => a.fixed).length;
407
+ L.push('');
408
+ L.push(` ${result.affected.length} affected package${result.affected.length === 1 ? '' : 's'}. ${fixable} ha${fixable === 1 ? 's' : 've'} a fixed version`);
409
+ if (fixable) L.push(' run atris sentinel watch --fix to update them on a new branch you can review');
410
+ L.push('');
411
+ return L.join('\n');
412
+ }
413
+
414
+ // ---------------------------------------------------------------- fix
415
+
416
+ function git(root, args, { run } = {}) {
417
+ const { execFileSync } = require('child_process');
418
+ const exec = run || ((cmd, a, o) => execFileSync(cmd, a, o));
419
+ return String(exec('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) || '').trim();
420
+ }
421
+
422
+ function rewriteRequirements(text, updates) {
423
+ const map = new Map(updates.map((u) => [u.name.toLowerCase().replace(/[-_.]+/g, '-'), u.fixed]));
424
+ let changed = 0;
425
+ const out = text.split('\n').map((line) => {
426
+ const m = line.match(/^(\s*)([A-Za-z0-9][A-Za-z0-9._-]*)((?:\[[^\]]*\])?\s*===?\s*)([0-9][A-Za-z0-9.!+-]*)(.*)$/);
427
+ if (!m) return line;
428
+ const fixed = map.get(m[2].toLowerCase().replace(/[-_.]+/g, '-'));
429
+ if (!fixed || compareVersions(fixed, m[4]) <= 0) return line;
430
+ changed += 1;
431
+ return `${m[1]}${m[2]}${m[3]}${fixed}${m[5]}`;
432
+ });
433
+ return { text: out.join('\n'), changed };
434
+ }
435
+
436
+ // Updates affected packages on a new branch. npm packages go through npm
437
+ // (direct ones by version, the rest by an override in package.json);
438
+ // requirements.txt pins are rewritten. Everything else is listed for a
439
+ // person to update.
440
+ function applyFix(root, result, { run, now = new Date(), log = () => {} } = {}) {
441
+ const fixable = result.affected.filter((a) => a.fixed);
442
+ if (!fixable.length) return { ok: true, changed: 0, manual: [], message: 'nothing has a fixed version yet' };
443
+ let status;
444
+ try { status = git(root, ['status', '--porcelain'], { run }); } catch { return { ok: false, reason: 'this folder is not a git repository. the fix needs a branch to go on' }; }
445
+ if (status) return { ok: false, reason: 'this folder has uncommitted changes. commit or stash them first so the fix branch holds only the update' };
446
+ let from = '';
447
+ try { from = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { run }); } catch {}
448
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
449
+ const branch = `sentinel/fix-dependencies-${stamp}`;
450
+ git(root, ['checkout', '-b', branch], { run });
451
+ const changedFiles = new Set();
452
+ const manual = [];
453
+ const npm = fixable.filter((a) => a.ecosystem === 'npm' && a.lockfile === 'package-lock.json');
454
+ const py = fixable.filter((a) => a.ecosystem === 'PyPI' && a.lockfile === 'requirements.txt');
455
+ for (const a of fixable) if (!npm.includes(a) && !py.includes(a)) manual.push(a);
456
+ if (py.length) {
457
+ const file = path.join(root, 'requirements.txt');
458
+ const r = rewriteRequirements(fs.readFileSync(file, 'utf8'), py);
459
+ if (r.changed) { fs.writeFileSync(file, r.text); changedFiles.add('requirements.txt'); }
460
+ }
461
+ if (npm.length) {
462
+ const pkgFile = path.join(root, 'package.json');
463
+ let pkg = null;
464
+ try { pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8')); } catch {}
465
+ if (!pkg) manual.push(...npm);
466
+ else {
467
+ const declared = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.optionalDependencies || {}) };
468
+ const direct = npm.filter((a) => declared[a.name] && a.direct !== false);
469
+ const transitive = npm.filter((a) => !direct.includes(a));
470
+ if (transitive.length) {
471
+ pkg.overrides = pkg.overrides || {};
472
+ for (const a of transitive) {
473
+ const cur = pkg.overrides[a.name];
474
+ if (typeof cur === 'string' && compareVersions(cur.replace(/^[^0-9]*/, ''), a.fixed) >= 0) continue;
475
+ if (cur && typeof cur !== 'string') { manual.push(a); continue; }
476
+ pkg.overrides[a.name] = `^${a.fixed}`;
477
+ }
478
+ fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
479
+ }
480
+ const { execFileSync } = require('child_process');
481
+ const exec = run || ((cmd, a, o) => execFileSync(cmd, a, o));
482
+ try {
483
+ // --ignore-scripts: no install script runs while fixing a security issue.
484
+ const specs = direct.map((a) => `${a.name}@^${a.fixed}`);
485
+ if (specs.length) exec('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', ...specs], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
486
+ else exec('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
487
+ changedFiles.add('package.json');
488
+ changedFiles.add('package-lock.json');
489
+ } catch (e) {
490
+ log(`npm could not update the packages: ${String(e.message || e).split('\n')[0]}`);
491
+ // Leave no half update: put package.json and the lockfile back.
492
+ try { git(root, ['checkout', '--', 'package.json', 'package-lock.json'], { run }); } catch {}
493
+ manual.push(...npm);
494
+ }
495
+ }
496
+ }
497
+ let committed = false;
498
+ const dirty = git(root, ['status', '--porcelain'], { run });
499
+ if (dirty) {
500
+ git(root, ['add', '-A', '--', ...[...changedFiles].filter((f) => fs.existsSync(path.join(root, f)))], { run });
501
+ try {
502
+ git(root, ['commit', '-m', 'Update dependencies with known security fixes\n\nMade by atris sentinel watch --fix. Review before merging.'], { run });
503
+ } catch (e) {
504
+ return { ok: false, branch, from, reason: `the update is on branch ${branch} but the commit did not go through: ${String(e.message || e).split('\n')[0]}`, manual };
505
+ }
506
+ committed = true;
507
+ } else {
508
+ // Nothing changed: drop the empty branch and go back.
509
+ try { if (from) git(root, ['checkout', from], { run }); git(root, ['branch', '-D', branch], { run }); } catch {}
510
+ }
511
+ return { ok: true, branch: committed ? branch : null, from, changed: committed ? changedFiles.size : 0, manual };
512
+ }
513
+
514
+ module.exports = {
515
+ OSV_BASE,
516
+ parsePackageLock,
517
+ parsePnpmLock,
518
+ parseYarnLock,
519
+ parseRequirements,
520
+ parsePoetryLock,
521
+ parseGoSum,
522
+ parseGemfileLock,
523
+ compareVersions,
524
+ fixedVersionFor,
525
+ runWatch,
526
+ toEvents,
527
+ renderReport,
528
+ applyFix,
529
+ };
package/lib/task-core.js CHANGED
@@ -120,8 +120,8 @@ atris task - durable local task state (SQLite, gitignored)
120
120
  atris task desk [--all] Show the full task desk
121
121
  atris task new "<title>" [--what-changes "..."] [--why-it-matters "..."] [--done-looks-like "..."] [--verify <cmd>]
122
122
  Create a task with a plain explanation; omitted fields get honest defaults
123
- atris task next [--tag <tag>] [--create-next]
124
- Same next command as bare atris and the task desk; --create-next still seeds Endgame fallback
123
+ atris task next [--tag <tag>] [--agent <member>|--human] [--create-next]
124
+ Show the next human or agent step; agent recommendations are read-only
125
125
  atris task continue-work <id> Create/reuse a certified Review follow-up task
126
126
  atris task say <id> "<message>" Add context to a task
127
127
  atris task chat <id> "<message>" [--goal "..."] Refine a task chat + working goal
@@ -308,7 +308,7 @@ function agentProofOnlyMode() {
308
308
  const explicit = process.env.ATRIS_AGENT_PROOF_ONLY;
309
309
  if (explicit === '1') return true;
310
310
  if (explicit === '0') return false;
311
- return AGENT_ENV_MARKERS.some((marker) => String(process.env[marker] || '').trim() !== '');
311
+ return Boolean(workerEnvMarker());
312
312
  }
313
313
 
314
314
  const WORKER_ENV_MARKERS = [
package/lib/task-db.js CHANGED
@@ -1431,14 +1431,22 @@ function recordReviewPassActor(metadata, claimedBy, rawActor, { authors = [], ha
1431
1431
  // Everyone who ever built the job, from the whole history the store holds: the stamp, the claimant,
1432
1432
  // every claim on file and every hand-in (see reviewIntegrity.taskAuthors). A Set of normalized
1433
1433
  // names, empty for a row that is not there.
1434
- function authorsOfTask(db, row) {
1435
- if (!row || typeof row !== 'object') return new Set();
1434
+ function taskAuthorship(db, row) {
1435
+ if (!row || typeof row !== 'object') return {};
1436
1436
  const events = row.id
1437
1437
  ? db.prepare("SELECT event_type, actor, payload FROM task_events WHERE task_id = ? AND event_type IN ('claimed', 'proof_ready') ORDER BY version")
1438
1438
  .all(row.id)
1439
1439
  .map((event) => ({ event_type: event.event_type, actor: event.actor, payload: safeJSON(event.payload) }))
1440
1440
  : [];
1441
- return reviewIntegrity.taskAuthors({ metadata: row.metadata, claimed_by: row.claimed_by, events });
1441
+ return { metadata: row.metadata, claimed_by: row.claimed_by, events };
1442
+ }
1443
+
1444
+ function authorsOfTask(db, row) {
1445
+ return reviewIntegrity.taskAuthors(taskAuthorship(db, row));
1446
+ }
1447
+
1448
+ function isAuthorOfTask(db, row, actor) {
1449
+ return reviewIntegrity.isTaskAuthor(taskAuthorship(db, row), actor);
1442
1450
  }
1443
1451
 
1444
1452
  // A merge the caller confirmed. The commit id is required; the other fields
@@ -3275,6 +3283,7 @@ module.exports = {
3275
3283
  relabelArchivedTasks,
3276
3284
  readyTask,
3277
3285
  authorsOfTask,
3286
+ isAuthorOfTask,
3278
3287
  heldPullRequestOf,
3279
3288
  pullRequestTakenBy,
3280
3289
  landHeldMerge,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.2",
3
+ "version": "3.82.4",
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",