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.
- package/README.md +1 -1
- package/atris/atris.md +6 -0
- package/bin/atris.js +1 -0
- package/commands/aeo.js +15 -2
- package/commands/next.js +16 -6
- package/commands/now.js +6 -1
- package/commands/security-review.js +239 -8
- package/commands/sentinel-powers.js +284 -0
- package/commands/task.js +9 -2
- package/lib/agent-next.js +255 -0
- package/lib/campaign-keep.js +1 -0
- package/lib/first-minute.js +13 -6
- package/lib/member-context.js +11 -2
- package/lib/security-audit.js +191 -12
- package/lib/sentinel-agents.js +584 -0
- package/lib/sentinel-cloud.js +561 -0
- package/lib/sentinel-events.js +320 -0
- package/lib/sentinel-guard-install.js +163 -0
- package/lib/sentinel-guard.js +856 -0
- package/lib/sentinel-watch.js +529 -0
- package/lib/task-core.js +3 -3
- package/lib/task-db.js +12 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
1435
|
-
if (!row || typeof row !== 'object') return
|
|
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
|
|
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.
|
|
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",
|