atris 3.82.7 → 3.82.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/atris.js +5 -2
- package/commands/security-review.js +1 -0
- package/commands/sentinel-powers.js +97 -11
- package/lib/sentinel-events.js +32 -10
- package/lib/sentinel-improve.js +6 -2
- package/lib/sentinel-watch.js +940 -98
- package/package.json +1 -1
package/lib/sentinel-watch.js
CHANGED
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
// (https://api.osv.dev, no key) which exact versions are affected, and
|
|
6
6
|
// reports each affected package with the version that fixes it.
|
|
7
7
|
//
|
|
8
|
+
// find lockfiles at the root and in subfolders (not node_modules, .venv,
|
|
9
|
+
// vendor, build output or git-ignored folders), up to MAX_LOCKFILES
|
|
8
10
|
// parse package-lock.json, pnpm-lock.yaml, yarn.lock, requirements.txt,
|
|
9
11
|
// poetry.lock, go.sum, Gemfile.lock (exact versions only)
|
|
10
12
|
// query POST /v1/querybatch returns advisory ids per package, then
|
|
11
13
|
// GET /v1/vulns/<id> gives the affected ranges and fixed versions
|
|
12
14
|
// fix --fix updates packages on a new git branch, commits there and
|
|
13
|
-
// never pushes. Never commits on main or master.
|
|
15
|
+
// never pushes. Never commits on main or master. Each lockfile is
|
|
16
|
+
// updated by its own tool. An npm or pnpm change is scoped to the
|
|
17
|
+
// package that pulls in the affected copy. A safe copy of the
|
|
18
|
+
// same package elsewhere never moves. A fix that jumps to a new
|
|
19
|
+
// major version waits for a person unless --allow-major is given.
|
|
14
20
|
//
|
|
15
21
|
// Only package names, versions and advisory ids go to OSV. No source code.
|
|
16
22
|
// A network failure is a warning in the result, never a throw.
|
|
@@ -22,6 +28,19 @@ const events = require('./sentinel-events');
|
|
|
22
28
|
const OSV_BASE = 'https://api.osv.dev';
|
|
23
29
|
const BATCH_SIZE = 1000;
|
|
24
30
|
const MAX_VULN_DETAILS = 400;
|
|
31
|
+
const MAX_LOCKFILES = 20;
|
|
32
|
+
const MAX_DEPTH = 6;
|
|
33
|
+
const MAX_DIRS = 5000;
|
|
34
|
+
// A lockfile larger than this is skipped with a warning, never read.
|
|
35
|
+
const MAX_LOCKFILE_BYTES = 20 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
// Folders that hold installed copies, build output or tool caches. Their
|
|
38
|
+
// lockfiles are not the project's own. Every folder starting with a dot is
|
|
39
|
+
// skipped too.
|
|
40
|
+
const SKIP_DIRS = new Set([
|
|
41
|
+
'node_modules', 'bower_components', 'jspm_packages', 'vendor', 'venv', 'env', 'virtualenv',
|
|
42
|
+
'site-packages', '__pycache__', 'dist', 'build', 'out', 'target', 'coverage', 'tmp', 'temp',
|
|
43
|
+
]);
|
|
25
44
|
|
|
26
45
|
// ---------------------------------------------------------------- parse
|
|
27
46
|
|
|
@@ -38,7 +57,7 @@ function parsePackageLock(text) {
|
|
|
38
57
|
if (!key || !v || v.link || !v.version) continue;
|
|
39
58
|
const at = key.lastIndexOf('node_modules/');
|
|
40
59
|
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) });
|
|
60
|
+
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), path: key });
|
|
42
61
|
}
|
|
43
62
|
return out;
|
|
44
63
|
}
|
|
@@ -54,13 +73,15 @@ function parsePackageLock(text) {
|
|
|
54
73
|
|
|
55
74
|
// pnpm keys: "/name/1.2.3:" (v5), "/name@1.2.3:" (v6), "name@1.2.3:" (v9),
|
|
56
75
|
// each with an optional "(peer@x)" suffix.
|
|
76
|
+
const PNPM_KEY = /^ {2}['"]?\/?((?:@[^/@\s]+\/)?[^/@\s(]+)[@/]([0-9][^:('"\s]*)(?:\([^:]*\))?['"]?:\s*$/;
|
|
77
|
+
|
|
57
78
|
function parsePnpmLock(text) {
|
|
58
79
|
const out = [];
|
|
59
80
|
let inPackages = false;
|
|
60
81
|
for (const line of text.split('\n')) {
|
|
61
82
|
if (/^\S/.test(line)) { inPackages = /^(?:packages|snapshots):\s*$/.test(line); continue; }
|
|
62
83
|
if (!inPackages) continue;
|
|
63
|
-
const m = line.match(
|
|
84
|
+
const m = line.match(PNPM_KEY);
|
|
64
85
|
if (!m) continue;
|
|
65
86
|
out.push({ name: m[1], version: m[2], ecosystem: 'npm' });
|
|
66
87
|
}
|
|
@@ -134,16 +155,18 @@ function parseGemfileLock(text) {
|
|
|
134
155
|
return out;
|
|
135
156
|
}
|
|
136
157
|
|
|
158
|
+
// tool: the program that owns the lockfile. label: how a person names it.
|
|
137
159
|
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 },
|
|
160
|
+
{ file: 'package-lock.json', tool: 'npm', label: 'npm lockfile', parse: parsePackageLock },
|
|
161
|
+
{ file: 'npm-shrinkwrap.json', tool: 'npm', label: 'npm lockfile', parse: parsePackageLock },
|
|
162
|
+
{ file: 'pnpm-lock.yaml', tool: 'pnpm', label: 'pnpm lockfile', parse: parsePnpmLock },
|
|
163
|
+
{ file: 'yarn.lock', tool: 'yarn', label: 'yarn lockfile', parse: parseYarnLock },
|
|
164
|
+
{ file: 'requirements.txt', tool: 'pip', label: 'python requirements file', parse: parseRequirements },
|
|
165
|
+
{ file: 'poetry.lock', tool: 'poetry', label: 'poetry lockfile', parse: parsePoetryLock },
|
|
166
|
+
{ file: 'go.sum', tool: 'go', label: 'go module list', parse: parseGoSum },
|
|
167
|
+
{ file: 'Gemfile.lock', tool: 'bundler', label: 'ruby gem lockfile', parse: parseGemfileLock },
|
|
146
168
|
];
|
|
169
|
+
const LOCKFILE_BY_NAME = new Map(LOCKFILES.map((l) => [l.file, l]));
|
|
147
170
|
|
|
148
171
|
// Only names and versions of a known shape go to OSV. A lockfile entry
|
|
149
172
|
// holding a URL, a path, a git address or anything else is skipped and
|
|
@@ -168,26 +191,85 @@ function looksReal(p) {
|
|
|
168
191
|
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
192
|
}
|
|
170
193
|
|
|
171
|
-
function
|
|
194
|
+
function pkgKey(p) {
|
|
195
|
+
return `${p.ecosystem}\u0000${p.name}\u0000${p.version}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function skipDir(name) {
|
|
199
|
+
return name.startsWith('.') || SKIP_DIRS.has(name);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Git-ignored files and folders under root, as root-relative paths. Folders
|
|
203
|
+
// end with a slash. Empty when root is not in a git repository.
|
|
204
|
+
function gitIgnored(root) {
|
|
205
|
+
const { spawnSync } = require('child_process');
|
|
206
|
+
const r = spawnSync('git', ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z', '--', '.'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
207
|
+
if (r.status !== 0 || typeof r.stdout !== 'string') return new Set();
|
|
208
|
+
return new Set(r.stdout.split('\0').filter(Boolean).map((p) => p.replace(/\\/g, '/')));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Every lockfile under root, shallow folders first. Never follows a symlink.
|
|
212
|
+
// Returns the first MAX_LOCKFILES and how many more were found.
|
|
213
|
+
function findLockfiles(root, { max = MAX_LOCKFILES, ignored = gitIgnored(root) } = {}) {
|
|
172
214
|
const found = [];
|
|
173
|
-
const
|
|
215
|
+
const queue = [{ rel: '', depth: 0 }];
|
|
216
|
+
let dirs = 0;
|
|
217
|
+
while (queue.length && dirs < MAX_DIRS) {
|
|
218
|
+
const { rel, depth } = queue.shift();
|
|
219
|
+
dirs += 1;
|
|
220
|
+
let entries;
|
|
221
|
+
try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
|
|
222
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
223
|
+
for (const e of entries) {
|
|
224
|
+
const child = rel ? `${rel}/${e.name}` : e.name;
|
|
225
|
+
if (e.isFile() && LOCKFILE_BY_NAME.has(e.name) && !ignored.has(child)) found.push(child);
|
|
226
|
+
}
|
|
227
|
+
if (depth >= MAX_DEPTH) continue;
|
|
228
|
+
for (const e of entries) {
|
|
229
|
+
if (!e.isDirectory() || skipDir(e.name)) continue;
|
|
230
|
+
const child = rel ? `${rel}/${e.name}` : e.name;
|
|
231
|
+
if (ignored.has(`${child}/`)) continue;
|
|
232
|
+
queue.push({ rel: child, depth: depth + 1 });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { files: found.slice(0, max), more: Math.max(0, found.length - max), dirCapped: queue.length > 0 };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Reads every lockfile found. packages: one entry per name and version,
|
|
239
|
+
// the list sent to OSV. occurrences: for each of those, every lockfile it
|
|
240
|
+
// appears in.
|
|
241
|
+
function readLockfiles(root, opts = {}) {
|
|
242
|
+
const { files, more, dirCapped } = findLockfiles(root, opts);
|
|
243
|
+
const maxBytes = opts.maxBytes || MAX_LOCKFILE_BYTES;
|
|
244
|
+
const lockfiles = [];
|
|
174
245
|
const packages = [];
|
|
246
|
+
const occurrences = new Map();
|
|
247
|
+
const tooBig = [];
|
|
248
|
+
const unreadable = [];
|
|
175
249
|
let skipped = 0;
|
|
176
|
-
for (const
|
|
177
|
-
const
|
|
250
|
+
for (const rel of files) {
|
|
251
|
+
const lf = LOCKFILE_BY_NAME.get(path.basename(rel));
|
|
252
|
+
const file = path.join(root, rel);
|
|
253
|
+
let st;
|
|
254
|
+
try { st = fs.lstatSync(file); } catch { unreadable.push(rel); continue; }
|
|
255
|
+
if (!st.isFile()) { unreadable.push(rel); continue; }
|
|
256
|
+
if (st.size > maxBytes) { tooBig.push(rel); continue; }
|
|
178
257
|
const text = readMaybe(file);
|
|
179
|
-
if (text == null) continue;
|
|
258
|
+
if (text == null) { unreadable.push(rel); continue; }
|
|
180
259
|
const list = lf.parse(text);
|
|
181
|
-
|
|
260
|
+
lockfiles.push({ file: rel, tool: lf.tool, packages: list.length });
|
|
182
261
|
for (const p of list) {
|
|
183
262
|
if (!looksReal(p)) { skipped += 1; continue; }
|
|
184
|
-
const key =
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
263
|
+
const key = pkgKey(p);
|
|
264
|
+
if (!occurrences.has(key)) { occurrences.set(key, []); packages.push({ name: p.name, version: p.version, ecosystem: p.ecosystem }); }
|
|
265
|
+
const occ = occurrences.get(key);
|
|
266
|
+
let here = occ.find((o) => o.lockfile === rel);
|
|
267
|
+
if (!here) { here = { lockfile: rel, tool: lf.tool, direct: false, paths: [] }; occ.push(here); }
|
|
268
|
+
if (p.direct) here.direct = true;
|
|
269
|
+
if (p.path) here.paths.push(p.path);
|
|
188
270
|
}
|
|
189
271
|
}
|
|
190
|
-
return { lockfiles
|
|
272
|
+
return { lockfiles, packages, occurrences, skipped, more, dirCapped, tooBig, unreadable };
|
|
191
273
|
}
|
|
192
274
|
|
|
193
275
|
// ---------------------------------------------------------------- versions
|
|
@@ -216,6 +298,17 @@ function compareVersions(a, b) {
|
|
|
216
298
|
return 0;
|
|
217
299
|
}
|
|
218
300
|
|
|
301
|
+
// A new major version line: 3.x to 4.x, or 0.3.x to 0.4.x, where a version
|
|
302
|
+
// range like ^0.3.0 would never reach on its own.
|
|
303
|
+
function isMajorJump(from, to) {
|
|
304
|
+
const a = parts(String(from).split('-')[0]);
|
|
305
|
+
const b = parts(String(to).split('-')[0]);
|
|
306
|
+
if (typeof a[0] !== 'number' || typeof b[0] !== 'number') return false;
|
|
307
|
+
if (b[0] !== a[0]) return b[0] > a[0];
|
|
308
|
+
if (a[0] === 0 && typeof a[1] === 'number' && typeof b[1] === 'number') return b[1] > a[1];
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
219
312
|
// The lowest fixed version above the version in use, from the advisory's
|
|
220
313
|
// ranges for this package. null when no fix is published.
|
|
221
314
|
function fixedVersionFor(vuln, pkg) {
|
|
@@ -306,10 +399,17 @@ function defaultRequest() {
|
|
|
306
399
|
return require('./sentinel-cloud').request;
|
|
307
400
|
}
|
|
308
401
|
|
|
309
|
-
async function runWatch(root, { base = process.env.ATRIS_OSV_BASE || OSV_BASE, request = defaultRequest() } = {}) {
|
|
310
|
-
const { lockfiles, packages, skipped } = readLockfiles(root);
|
|
311
|
-
|
|
402
|
+
async function runWatch(root, { base = process.env.ATRIS_OSV_BASE || OSV_BASE, request = defaultRequest(), maxLockfiles = MAX_LOCKFILES, maxBytes = MAX_LOCKFILE_BYTES } = {}) {
|
|
403
|
+
const { lockfiles, packages, occurrences, skipped, more, dirCapped, tooBig, unreadable } = readLockfiles(root, { max: maxLockfiles, maxBytes });
|
|
404
|
+
// A scan that left any lockfile unread cannot say a threat is gone.
|
|
405
|
+
const incomplete = Boolean(more || dirCapped || tooBig.length || unreadable.length);
|
|
406
|
+
const result = { root: path.resolve(root), lockfiles, packages: packages.length, skipped, more, tooBig, unreadable, incomplete, affected: [], warnings: [] };
|
|
407
|
+
const mb = Math.round(maxBytes / (1024 * 1024));
|
|
408
|
+
for (const rel of tooBig) result.warnings.push(`skipped ${rel}: it is larger than ${mb} MB`);
|
|
409
|
+
for (const rel of unreadable) result.warnings.push(`could not read ${rel}`);
|
|
410
|
+
if (dirCapped) result.warnings.push(`stopped looking for lockfiles after ${MAX_DIRS} folders. run atris sentinel watch on a subfolder to check the rest`);
|
|
312
411
|
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`);
|
|
412
|
+
if (more) result.warnings.push(`found ${more} more lockfile${more === 1 ? '' : 's'} past the limit of ${maxLockfiles}. ${more === 1 ? 'it was' : 'they were'} not read. run atris sentinel watch on a subfolder to check ${more === 1 ? 'it' : 'them'}`);
|
|
313
413
|
if (!packages.length) return result;
|
|
314
414
|
let ids;
|
|
315
415
|
try {
|
|
@@ -333,9 +433,13 @@ async function runWatch(root, { base = process.env.ATRIS_OSV_BASE || OSV_BASE, r
|
|
|
333
433
|
const fixed = fixes.length ? fixes.sort(compareVersions)[fixes.length - 1] : null;
|
|
334
434
|
const malicious = vids.some((id) => /^MAL-/.test(id));
|
|
335
435
|
const severity = vulns.map(severityOf).sort((a, b) => SEV_RANK[a] - SEV_RANK[b])[0] || 'medium';
|
|
336
|
-
|
|
436
|
+
const major = Boolean(fixed && isMajorJump(pkg.version, fixed));
|
|
437
|
+
// One finding per lockfile the affected version appears in.
|
|
438
|
+
for (const occ of occurrences.get(pkgKey(pkg)) || []) {
|
|
439
|
+
result.affected.push({ ...pkg, lockfile: occ.lockfile, tool: occ.tool, direct: occ.direct, paths: occ.paths, advisories: vids, fixed, major, malicious, severity, unfixed: fixes.length < vids.length });
|
|
440
|
+
}
|
|
337
441
|
});
|
|
338
|
-
result.affected.sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.name.localeCompare(b.name));
|
|
442
|
+
result.affected.sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.name.localeCompare(b.name) || compareVersions(a.version, b.version) || a.lockfile.localeCompare(b.lockfile));
|
|
339
443
|
return result;
|
|
340
444
|
}
|
|
341
445
|
|
|
@@ -357,43 +461,98 @@ function threatTitle(a) {
|
|
|
357
461
|
return events.fillPlain(`A known security hole affects ${subject}. No fixed version is published yet.`, slots);
|
|
358
462
|
}
|
|
359
463
|
|
|
360
|
-
|
|
464
|
+
// Where a finding lives, in words the dashboard accepts: "the pnpm lockfile
|
|
465
|
+
// in the web folder". The file path itself stays in the local report.
|
|
466
|
+
function lockfilePlace(rel) {
|
|
467
|
+
if (!rel) return '';
|
|
468
|
+
const lf = LOCKFILE_BY_NAME.get(path.basename(rel));
|
|
469
|
+
const label = lf ? lf.label : 'lockfile';
|
|
470
|
+
const dir = path.dirname(rel);
|
|
471
|
+
if (dir === '.' || dir === '') return events.fillPlain(`Found in the ${label} at the top of the project.`, {}, 300);
|
|
472
|
+
const folder = path.basename(dir);
|
|
473
|
+
if (events.slotOk('word', folder)) return events.fillPlain(`Found in the ${label} in the {dir} folder.`, { dir: ['word', folder, 'project'] }, 300);
|
|
474
|
+
return events.fillPlain(`Found in the ${label} in a subfolder.`, {}, 300);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function threatDetail(a, lockfiles = a.lockfile ? [a.lockfile] : []) {
|
|
361
478
|
const one = a.advisories.length === 1;
|
|
362
479
|
const slots = { n: ['count', String(a.advisories.length), 'some'], fixed: ['version', a.fixed, 'a newer version'], current: ['version', a.version, 'the current version'] };
|
|
363
480
|
const found = one ? 'Found in one published advisory against version {current}.' : 'Found in {n} published advisories against version {current}.';
|
|
364
481
|
const head = a.fixed
|
|
365
482
|
? events.fillPlain(`${found} Update to version {fixed} or later and run your tests.`, slots, 1000)
|
|
366
483
|
: events.fillPlain(`${found} Watch for a fix or replace the package.`, slots, 1000);
|
|
484
|
+
const parts2 = [head];
|
|
485
|
+
if (a.fixed && a.major) parts2.push(events.fillPlain('That is a new major version. Check your code before you take it.', {}, 300));
|
|
486
|
+
if (lockfiles.length === 1) parts2.push(lockfilePlace(lockfiles[0]));
|
|
487
|
+
else if (lockfiles.length > 1) parts2.push(events.fillPlain('Found in {n} lockfiles in this project.', { n: ['count', String(lockfiles.length), 'several'] }, 300));
|
|
367
488
|
// Advisory codes stay in the local report: mixed letter and digit codes read as keys to the dashboard's secret check.
|
|
368
|
-
return
|
|
489
|
+
return parts2.filter(Boolean).join(' ').slice(0, 1000);
|
|
369
490
|
}
|
|
370
491
|
|
|
492
|
+
// One event per package and version, however many lockfiles hold it. That
|
|
493
|
+
// way the fingerprint stays the same as before lockfile paths were tracked.
|
|
371
494
|
function toEvents(result, { repo, now = new Date() } = {}) {
|
|
372
495
|
const name = repo || events.repoName(result.root);
|
|
373
|
-
|
|
496
|
+
const groups = new Map();
|
|
497
|
+
for (const a of result.affected) {
|
|
498
|
+
const fp = events.fingerprint('threat', a.ecosystem, a.name, a.version);
|
|
499
|
+
if (!groups.has(fp)) groups.set(fp, { a, lockfiles: [] });
|
|
500
|
+
if (a.lockfile && !groups.get(fp).lockfiles.includes(a.lockfile)) groups.get(fp).lockfiles.push(a.lockfile);
|
|
501
|
+
}
|
|
502
|
+
return [...groups.entries()].map(([fp, { a, lockfiles }]) => events.makeEvent({
|
|
374
503
|
kind: 'threat',
|
|
375
504
|
repo: name,
|
|
376
505
|
severity: a.severity,
|
|
377
506
|
title: threatTitle(a),
|
|
378
|
-
detail: threatDetail(a),
|
|
507
|
+
detail: threatDetail(a, lockfiles),
|
|
379
508
|
status: 'open',
|
|
380
509
|
// Per package and version: updating it changes the version, so the old
|
|
381
510
|
// finding resolves on the next run.
|
|
382
|
-
fingerprint:
|
|
511
|
+
fingerprint: fp,
|
|
383
512
|
occurredAt: now,
|
|
384
513
|
}));
|
|
385
514
|
}
|
|
386
515
|
|
|
516
|
+
// For each threat fingerprint, the lockfiles it was found in. Kept in the
|
|
517
|
+
// local state so a later run can tell whether it read those files again.
|
|
518
|
+
function lockfilesByFingerprint(result) {
|
|
519
|
+
const out = new Map();
|
|
520
|
+
for (const a of result.affected) {
|
|
521
|
+
const fp = events.fingerprint('threat', a.ecosystem, a.name, a.version);
|
|
522
|
+
if (!out.has(fp)) out.set(fp, { lockfiles: [] });
|
|
523
|
+
if (a.lockfile && !out.get(fp).lockfiles.includes(a.lockfile)) out.get(fp).lockfiles.push(a.lockfile);
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Fingerprints of every package version in a list of lockfile texts. Used
|
|
529
|
+
// to tell whether the default branch still holds an affected version.
|
|
530
|
+
function fingerprintsIn(files) {
|
|
531
|
+
const out = new Set();
|
|
532
|
+
for (const { file, text } of files) {
|
|
533
|
+
const lf = LOCKFILE_BY_NAME.get(path.basename(file));
|
|
534
|
+
if (!lf || typeof text !== 'string') continue;
|
|
535
|
+
for (const p of lf.parse(text)) if (looksReal(p)) out.add(events.fingerprint('threat', p.ecosystem, p.name, p.version));
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function uniqueCount(affected) {
|
|
541
|
+
return new Set(affected.map(pkgKey)).size;
|
|
542
|
+
}
|
|
543
|
+
|
|
387
544
|
function renderReport(result) {
|
|
388
545
|
const L = [''];
|
|
389
|
-
const files = result.lockfiles.map((l) => l.file).join(', ');
|
|
390
546
|
if (!result.lockfiles.length) {
|
|
391
547
|
L.push(' sentinel watch: no lockfile found here. there is nothing to check');
|
|
392
|
-
L.push(' it reads package-lock.json, pnpm-lock.yaml, yarn.lock, requirements.txt, poetry.lock, go.sum and Gemfile.lock');
|
|
548
|
+
L.push(' it reads package-lock.json, pnpm-lock.yaml, yarn.lock, requirements.txt, poetry.lock, go.sum and Gemfile.lock in this folder and its subfolders');
|
|
549
|
+
for (const w of result.warnings) L.push(` warning: ${w}`);
|
|
393
550
|
L.push('');
|
|
394
551
|
return L.join('\n');
|
|
395
552
|
}
|
|
396
|
-
|
|
553
|
+
const n = result.lockfiles.length;
|
|
554
|
+
L.push(` sentinel watch: checked ${result.packages} package versions from ${n} lockfile${n === 1 ? '' : 's'} against the osv database`);
|
|
555
|
+
L.push(` read: ${result.lockfiles.map((l) => l.file).join(', ')}`);
|
|
397
556
|
for (const w of result.warnings) L.push(` warning: ${w}`);
|
|
398
557
|
L.push('');
|
|
399
558
|
if (result.offline) return L.join('\n');
|
|
@@ -404,14 +563,17 @@ function renderReport(result) {
|
|
|
404
563
|
}
|
|
405
564
|
for (const a of result.affected.slice(0, 80)) {
|
|
406
565
|
const what = a.malicious ? 'published attack' : 'security hole';
|
|
407
|
-
const fix = a.fixed ? `fixed in ${a.fixed}` : 'no fix yet';
|
|
408
|
-
L.push(` ${a.severity.padEnd(8)} ${a.name} ${a.version}
|
|
566
|
+
const fix = a.fixed ? `fixed in ${a.fixed}${a.major ? '. that is a new major version. check your code before you take it' : ''}` : 'no fix yet';
|
|
567
|
+
L.push(` ${a.severity.padEnd(8)} ${a.name} ${a.version} in ${a.lockfile || a.ecosystem}: ${what}. ${fix}`);
|
|
409
568
|
L.push(` ${a.advisories.slice(0, 4).join(' ')}${a.advisories.length > 4 ? ` and ${a.advisories.length - 4} more` : ''}`);
|
|
410
569
|
}
|
|
411
570
|
if (result.affected.length > 80) L.push(` and ${result.affected.length - 80} more. run with --json to see all`);
|
|
412
|
-
const
|
|
571
|
+
const unique = uniqueCount(result.affected);
|
|
572
|
+
const fixable = uniqueCount(result.affected.filter((a) => a.fixed));
|
|
573
|
+
const majors = uniqueCount(result.affected.filter((a) => a.fixed && a.major));
|
|
413
574
|
L.push('');
|
|
414
|
-
L.push(` ${
|
|
575
|
+
L.push(` ${unique} affected package${unique === 1 ? '' : 's'}. ${fixable} ha${fixable === 1 ? 's' : 've'} a fixed version`);
|
|
576
|
+
if (majors) L.push(` ${majors} of those fix${majors === 1 ? 'es' : ''} only in a new major version. --fix leaves ${majors === 1 ? 'it' : 'them'} for a person unless you add --allow-major`);
|
|
415
577
|
if (fixable) L.push(' run atris sentinel watch --fix to update them on a new branch you can review');
|
|
416
578
|
L.push('');
|
|
417
579
|
return L.join('\n');
|
|
@@ -419,10 +581,13 @@ function renderReport(result) {
|
|
|
419
581
|
|
|
420
582
|
// ---------------------------------------------------------------- fix
|
|
421
583
|
|
|
422
|
-
function
|
|
584
|
+
function execFor(run) {
|
|
423
585
|
const { execFileSync } = require('child_process');
|
|
424
|
-
|
|
425
|
-
|
|
586
|
+
return run || ((cmd, a, o) => execFileSync(cmd, a, o));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function git(root, args, { run } = {}) {
|
|
590
|
+
return String(execFor(run)('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) || '').trim();
|
|
426
591
|
}
|
|
427
592
|
|
|
428
593
|
function rewriteRequirements(text, updates) {
|
|
@@ -439,86 +604,758 @@ function rewriteRequirements(text, updates) {
|
|
|
439
604
|
return { text: out.join('\n'), changed };
|
|
440
605
|
}
|
|
441
606
|
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
607
|
+
// ---- tool errors
|
|
608
|
+
|
|
609
|
+
function stripAnsi(s) {
|
|
610
|
+
return String(s || '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// The tool's own words, one or two lines. execFileSync puts the real error
|
|
614
|
+
// in stderr and leaves only "Command failed: ..." in the message.
|
|
615
|
+
function toolErrorLines(e, tool) {
|
|
616
|
+
if (e && e.code === 'ENOENT') return [`${tool} is not installed here`];
|
|
617
|
+
const text = `${stripAnsi(e && e.stderr)}\n${stripAnsi(e && e.stdout)}`;
|
|
618
|
+
const lines = [];
|
|
619
|
+
for (const raw of text.split('\n')) {
|
|
620
|
+
let l = raw.replace(/^\s*(?:npm (?:error|ERR!|warn|WARN)|pnpm:?|yarn (?:error|warn)|error|ERROR| ?ERR_PNPM_[A-Z_]+)\s*/, '').trim();
|
|
621
|
+
if (!l) continue;
|
|
622
|
+
if (/complete log of this run|^a complete log|\.log$|^code\s+\S+$|^errno\b|^syscall\b|^path\s+\//i.test(l)) continue;
|
|
623
|
+
if (/^Command failed:/.test(l)) continue;
|
|
624
|
+
l = l.replace(/\s+/g, ' ');
|
|
625
|
+
if (l.length > 220) l = `${l.slice(0, 217)}...`;
|
|
626
|
+
if (!lines.includes(l)) lines.push(l);
|
|
627
|
+
if (lines.length >= 2) break;
|
|
628
|
+
}
|
|
629
|
+
if (lines.length) return lines;
|
|
630
|
+
const status = e && Number.isInteger(e.status) ? e.status : null;
|
|
631
|
+
return [`${tool} stopped${status !== null ? ` with exit code ${status}` : ''} and printed no reason`];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// A crash inside the tool or a version problem, where a newer copy of the
|
|
635
|
+
// tool often gets past it.
|
|
636
|
+
const TOOL_BUG = /cannot read propert(?:y|ies) of (?:null|undefined)|is not a function|is not iterable|invalid version|eresolve|maximum call stack|ebadengine|unsupported engine|err_invalid_arg_type|exit handler never called|tracker .* already exists|could not resolve dependency|conflicting peer|peer dep|unsupported (?:lockfile|version)|lockfile.*(?:version|format)|requires (?:npm|pnpm|node)/i;
|
|
637
|
+
|
|
638
|
+
function toolError(tool, lockfile, e, { exec, cwd }) {
|
|
639
|
+
const lines = toolErrorLines(e, tool);
|
|
640
|
+
const out = { tool, lockfile, lines };
|
|
641
|
+
if (e && e.code === 'ENOENT') return out;
|
|
642
|
+
const text = `${stripAnsi(e && e.stderr)}\n${stripAnsi(e && e.stdout)}`;
|
|
643
|
+
if ((tool === 'npm' || tool === 'pnpm' || tool === 'yarn') && TOOL_BUG.test(text)) {
|
|
644
|
+
let version = '';
|
|
645
|
+
try { version = String(exec(tool, ['--version'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) || '').trim().split('\n')[0]; } catch {}
|
|
646
|
+
if (!/^\d+\.\d+\.\d+/.test(version)) version = '';
|
|
647
|
+
out.version = version || null;
|
|
648
|
+
out.hint = `this looks like ${tool === 'npm' ? 'an' : 'a'} ${tool} bug or a version problem. ${version ? `${tool} ${version} is installed here. ` : ''}a newer ${tool} may get past it: npm install -g ${tool}@latest`;
|
|
649
|
+
}
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---- npm graph
|
|
654
|
+
|
|
655
|
+
// Where node finds `name` when required from the package at `from`: the
|
|
656
|
+
// nearest node_modules folder up the tree that holds it.
|
|
657
|
+
function npmResolve(packages, from, name) {
|
|
658
|
+
let base = from;
|
|
659
|
+
for (;;) {
|
|
660
|
+
const at = base ? `${base}/node_modules/${name}` : `node_modules/${name}`;
|
|
661
|
+
if (packages[at]) return at;
|
|
662
|
+
if (!base) return null;
|
|
663
|
+
const i = base.lastIndexOf('/node_modules/');
|
|
664
|
+
if (i !== -1) base = base.slice(0, i);
|
|
665
|
+
else if (base.startsWith('node_modules/')) base = '';
|
|
666
|
+
else base = ''; // a workspace folder resolves next from the root
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function npmEntryName(packages, key) {
|
|
671
|
+
const v = packages[key] || {};
|
|
672
|
+
if (v.name) return v.name;
|
|
673
|
+
const at = key.lastIndexOf('node_modules/');
|
|
674
|
+
return at === -1 ? key : key.slice(at + 'node_modules/'.length);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Who pulls in the copy at `target`. A parent with no node_modules in its key
|
|
678
|
+
// is the project itself or one of its workspace folders.
|
|
679
|
+
function npmParents(packages, target) {
|
|
680
|
+
const name = npmEntryName(packages, target);
|
|
681
|
+
const out = [];
|
|
682
|
+
for (const [key, v] of Object.entries(packages)) {
|
|
683
|
+
if (!v || v.link) continue;
|
|
684
|
+
const isProject = !key.includes('node_modules/');
|
|
685
|
+
const deps = { ...(v.dependencies || {}), ...(v.optionalDependencies || {}), ...(v.peerDependencies || {}), ...(isProject ? (v.devDependencies || {}) : {}) };
|
|
686
|
+
if (!(name in deps)) continue;
|
|
687
|
+
if (npmResolve(packages, key, name) !== target) continue;
|
|
688
|
+
out.push(isProject ? { project: key } : { name: npmEntryName(packages, key), version: String(v.version || '') });
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---- pnpm graph
|
|
694
|
+
|
|
695
|
+
function cleanPnpmVersion(v) {
|
|
696
|
+
const s = String(v || '').trim().replace(/^['"]|['"]$/g, '');
|
|
697
|
+
if (/^(?:link|file|workspace):/.test(s)) return null;
|
|
698
|
+
const m = s.match(/^([0-9][^(\s]*)/);
|
|
699
|
+
return m ? m[1] : null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function unquote(s) {
|
|
703
|
+
return String(s).trim().replace(/^['"]|['"]$/g, '');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Edges of a pnpm lockfile: parent (a package or a project folder) to the
|
|
707
|
+
// exact child version it uses. Handles the importers section (v6 and v9),
|
|
708
|
+
// the old top-level dependencies (v5) and packages or snapshots.
|
|
709
|
+
function pnpmGraph(text) {
|
|
710
|
+
const edges = [];
|
|
711
|
+
let section = '';
|
|
712
|
+
let importer = null;
|
|
713
|
+
let parent = null;
|
|
714
|
+
let depBlock = false;
|
|
715
|
+
let pendingName = null;
|
|
716
|
+
for (const line of text.split('\n')) {
|
|
717
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
718
|
+
const indent = line.match(/^ */)[0].length;
|
|
719
|
+
if (indent === 0) {
|
|
720
|
+
section = line.replace(/:.*$/, '').trim();
|
|
721
|
+
importer = null; parent = null; depBlock = false; pendingName = null;
|
|
722
|
+
if (['dependencies', 'devDependencies', 'optionalDependencies'].includes(section)) { importer = '.'; depBlock = true; }
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (section === 'importers') {
|
|
726
|
+
if (indent === 2) { importer = unquote(line.replace(/:\s*$/, '')); depBlock = false; pendingName = null; continue; }
|
|
727
|
+
if (indent === 4) { depBlock = /^\s{4}(?:dependencies|devDependencies|optionalDependencies):\s*$/.test(line); pendingName = null; continue; }
|
|
728
|
+
if (indent === 6 && depBlock) { pendingName = unquote(line.trim().replace(/:\s*$/, '')); continue; }
|
|
729
|
+
if (indent === 8 && depBlock && pendingName) {
|
|
730
|
+
const m = line.trim().match(/^version:\s*(.+)$/);
|
|
731
|
+
if (m) { const v = cleanPnpmVersion(m[1]); if (v) edges.push({ from: { project: importer }, name: pendingName, version: v }); }
|
|
732
|
+
}
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (['dependencies', 'devDependencies', 'optionalDependencies'].includes(section)) {
|
|
736
|
+
// v5: " name: 1.2.3"; v6 single project: " name:" then " version: 1.2.3"
|
|
737
|
+
if (indent === 2) {
|
|
738
|
+
const m = line.trim().match(/^(['"]?[^:'"]+['"]?):\s*(.*)$/);
|
|
739
|
+
if (!m) continue;
|
|
740
|
+
pendingName = unquote(m[1]);
|
|
741
|
+
const v = m[2] ? cleanPnpmVersion(m[2].replace(/^\//, '')) : null;
|
|
742
|
+
if (v) { edges.push({ from: { project: '.' }, name: pendingName, version: v }); pendingName = null; }
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (indent === 4 && pendingName) {
|
|
746
|
+
const m = line.trim().match(/^version:\s*(.+)$/);
|
|
747
|
+
if (m) { const v = cleanPnpmVersion(m[1]); if (v) edges.push({ from: { project: '.' }, name: pendingName, version: v }); }
|
|
748
|
+
}
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (section === 'packages' || section === 'snapshots') {
|
|
752
|
+
if (indent === 2) {
|
|
753
|
+
const m = line.match(PNPM_KEY);
|
|
754
|
+
parent = m ? { name: m[1], version: m[2] } : null;
|
|
755
|
+
depBlock = false;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (indent === 4) { depBlock = /^\s{4}(?:dependencies|optionalDependencies):\s*$/.test(line); continue; }
|
|
759
|
+
if (indent === 6 && depBlock && parent) {
|
|
760
|
+
const m = line.trim().match(/^(['"]?[^:'"]+['"]?):\s*(.+)$/);
|
|
761
|
+
if (!m) continue;
|
|
762
|
+
const raw = m[2].trim().replace(/^\//, '');
|
|
763
|
+
// v5 can write a child as "/name/1.2.3"; plain "1.2.3" is the usual shape.
|
|
764
|
+
const v = cleanPnpmVersion(raw.includes('/') ? raw.split('/').pop() : raw);
|
|
765
|
+
if (v) edges.push({ from: parent, name: unquote(m[1]), version: v });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return edges;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function pnpmParents(edges, name, version) {
|
|
773
|
+
const out = [];
|
|
774
|
+
const seen = new Set();
|
|
775
|
+
for (const e of edges) {
|
|
776
|
+
if (e.name !== name || e.version !== version) continue;
|
|
777
|
+
const id = e.from.project !== undefined ? `p:${e.from.project}` : `${e.from.name}@${e.from.version}`;
|
|
778
|
+
if (seen.has(id)) continue;
|
|
779
|
+
seen.add(id);
|
|
780
|
+
out.push(e.from);
|
|
781
|
+
}
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---- package.json edits
|
|
786
|
+
|
|
787
|
+
function readJson(file) {
|
|
788
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Every write --fix makes goes through here. It refuses a file that is a
|
|
792
|
+
// symlink, sits under a symlinked folder or lands outside the repo.
|
|
793
|
+
function safeWrite(root, file, text) {
|
|
794
|
+
if (!insideRepo(root, file)) throw new Error(`refused to write ${file}: it is outside this repo or behind a symlink`);
|
|
795
|
+
fs.writeFileSync(file, text);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function writeJson(root, file, obj) {
|
|
799
|
+
safeWrite(root, file, `${JSON.stringify(obj, null, 2)}\n`);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Raise a direct dependency's range in the section it already lives in.
|
|
803
|
+
// Returns false when the range is not a plain version range we can raise.
|
|
804
|
+
function raiseDirect(pkg, name, fixed) {
|
|
805
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
806
|
+
const deps = pkg[section];
|
|
807
|
+
if (!deps || typeof deps[name] !== 'string') continue;
|
|
808
|
+
const cur = deps[name];
|
|
809
|
+
const m = cur.match(/^([\^~]|>=)?\s*v?(\d[^\s|]*)$/);
|
|
810
|
+
if (!m) return false;
|
|
811
|
+
// An open range like >=1.0.1 could reach any later major. It becomes
|
|
812
|
+
// ^fixed. That stays on the fixed version's major line.
|
|
813
|
+
if (m[1] !== '>=' && compareVersions(m[2], fixed) >= 0) return true;
|
|
814
|
+
deps[name] = `${m[1] === '~' ? '~' : (m[1] ? '^' : '')}${fixed}`;
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function versionsOf(list, name) {
|
|
821
|
+
return new Set(list.filter((p) => p.name === name).map((p) => p.version));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ---- per lockfile
|
|
825
|
+
|
|
826
|
+
// The files one lockfile step may change, as they were before it. keep()
|
|
827
|
+
// must run before a file is written. Then restore() can put it back.
|
|
828
|
+
// Restore uses the same symlink and repo check as every other write.
|
|
829
|
+
function snapshot(root, files = []) {
|
|
830
|
+
const saved = new Map();
|
|
831
|
+
const snap = {
|
|
832
|
+
keep(f) { if (!saved.has(f)) saved.set(f, readMaybe(f)); },
|
|
833
|
+
restore() {
|
|
834
|
+
for (const [f, text] of saved) {
|
|
835
|
+
if (!insideRepo(root, f)) continue;
|
|
836
|
+
if (text == null) { try { fs.rmSync(f, { force: true }); } catch {} } else { try { safeWrite(root, f, text); } catch {} }
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
changed() { return [...saved].filter(([f, text]) => readMaybe(f) !== text).map(([f]) => f); },
|
|
840
|
+
files() { return [...saved.keys()]; },
|
|
841
|
+
};
|
|
842
|
+
for (const f of files) snap.keep(f);
|
|
843
|
+
return snap;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Plans and writes the package.json edits for an npm or pnpm lockfile.
|
|
847
|
+
// Returns the items that could not be planned, each with a reason.
|
|
848
|
+
function planNodeEdits(tool, dir, lockText, items, { allCopies, snap, root }) {
|
|
849
|
+
const pkgFile = path.join(dir, 'package.json');
|
|
850
|
+
const pkg = readJson(pkgFile);
|
|
851
|
+
const left = [];
|
|
852
|
+
if (!pkg) return { left: items.map((a) => ({ a, why: `there is no package.json next to ${a.lockfile}` })) };
|
|
853
|
+
const projectFiles = new Map([['.', { file: pkgFile, pkg }], ['', { file: pkgFile, pkg }]]);
|
|
854
|
+
const project = (p) => {
|
|
855
|
+
const key = p === '' ? '.' : p;
|
|
856
|
+
if (!projectFiles.has(key)) {
|
|
857
|
+
const file = path.join(dir, key, 'package.json');
|
|
858
|
+
projectFiles.set(key, { file, pkg: insideRepo(root, file) ? readJson(file) : null });
|
|
859
|
+
}
|
|
860
|
+
return projectFiles.get(key);
|
|
861
|
+
};
|
|
862
|
+
let lock = null;
|
|
863
|
+
let edges = null;
|
|
864
|
+
if (tool === 'npm') { try { lock = JSON.parse(lockText); } catch {} }
|
|
865
|
+
else edges = pnpmGraph(lockText);
|
|
866
|
+
const overrides = () => {
|
|
867
|
+
if (tool === 'npm') { pkg.overrides = pkg.overrides || {}; return pkg.overrides; }
|
|
868
|
+
pkg.pnpm = pkg.pnpm || {};
|
|
869
|
+
pkg.pnpm.overrides = pkg.pnpm.overrides || {};
|
|
870
|
+
return pkg.pnpm.overrides;
|
|
871
|
+
};
|
|
872
|
+
for (const a of items) {
|
|
873
|
+
let parents = [];
|
|
874
|
+
if (tool === 'npm') {
|
|
875
|
+
const packages = (lock && lock.packages) || null;
|
|
876
|
+
if (!packages) { left.push({ a, why: 'this npm lockfile is too old to read who pulls each package in' }); continue; }
|
|
877
|
+
const targets = Object.keys(packages).filter((k) => k.includes('node_modules/') && npmEntryName(packages, k) === a.name && String(packages[k].version) === a.version);
|
|
878
|
+
for (const t of targets) parents.push(...npmParents(packages, t));
|
|
879
|
+
} else {
|
|
880
|
+
parents = pnpmParents(edges, a.name, a.version);
|
|
881
|
+
}
|
|
882
|
+
const seen = new Set();
|
|
883
|
+
parents = parents.filter((p) => { const id = p.project !== undefined ? `p:${p.project || '.'}` : `${p.name}@${p.version}`; if (seen.has(id)) return false; seen.add(id); return true; });
|
|
884
|
+
if (!parents.length) {
|
|
885
|
+
// Nobody we can name pulls it in. A plain override is safe only when
|
|
886
|
+
// every copy of this package in the lockfile is the affected version.
|
|
887
|
+
const versions = versionsOf(allCopies, a.name);
|
|
888
|
+
if (versions.size === 1 && versions.has(a.version)) {
|
|
889
|
+
const o = overrides();
|
|
890
|
+
if (typeof o[a.name] === 'object') { left.push({ a, why: 'package.json already has a nested override for it' }); continue; }
|
|
891
|
+
o[a.name] = `^${a.fixed}`;
|
|
892
|
+
} else left.push({ a, why: 'could not tell which package pulls in this copy. other copies of it are safe and must not move' });
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
let ok = true;
|
|
896
|
+
for (const p of parents) {
|
|
897
|
+
if (p.project !== undefined) {
|
|
898
|
+
const proj = project(p.project);
|
|
899
|
+
if (!proj.pkg || !raiseDirect(proj.pkg, a.name, a.fixed)) { ok = false; break; }
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const o = overrides();
|
|
903
|
+
if (tool === 'npm') {
|
|
904
|
+
const key = `${p.name}@${p.version}`;
|
|
905
|
+
if (typeof o[key] === 'string') { ok = false; break; }
|
|
906
|
+
o[key] = o[key] || {};
|
|
907
|
+
const cur = o[key][a.name];
|
|
908
|
+
if (typeof cur === 'object') { ok = false; break; }
|
|
909
|
+
if (typeof cur === 'string' && compareVersions(cur.replace(/^[^0-9]*/, ''), a.fixed) >= 0) continue;
|
|
910
|
+
o[key][a.name] = `^${a.fixed}`;
|
|
911
|
+
} else {
|
|
912
|
+
const key = `${p.name}@${p.version}>${a.name}`;
|
|
913
|
+
const cur = o[key];
|
|
914
|
+
if (typeof cur === 'string' && compareVersions(cur.replace(/^[^0-9]*/, ''), a.fixed) >= 0) continue;
|
|
915
|
+
o[key] = `^${a.fixed}`;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (!ok) left.push({ a, why: 'package.json already pins it in a way this fix will not change' });
|
|
919
|
+
}
|
|
920
|
+
const toWrite = [];
|
|
921
|
+
for (const { file, pkg: p } of projectFiles.values()) if (p && !toWrite.some((w) => w.file === file)) toWrite.push({ file, pkg: p });
|
|
922
|
+
// All or nothing: every manifest is checked before the first one is written.
|
|
923
|
+
const bad = toWrite.find((w) => !insideRepo(root, w.file));
|
|
924
|
+
if (bad) throw new Error(`${path.relative(root, bad.file)} is outside this repo or behind a symlink`);
|
|
925
|
+
for (const w of toWrite) { snap.keep(w.file); writeJson(root, w.file, w.pkg); }
|
|
926
|
+
return { left };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ---- staying inside the repo
|
|
930
|
+
|
|
931
|
+
// True when target is root or under it and no folder or file between them
|
|
932
|
+
// is a symlink. A path that does not exist yet counts by its spelling, as
|
|
933
|
+
// long as the part that exists has no symlink.
|
|
934
|
+
function insideRepo(root, target) {
|
|
935
|
+
const base = path.resolve(root);
|
|
936
|
+
const rel = path.relative(base, path.resolve(target));
|
|
937
|
+
if (rel === '') return true;
|
|
938
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
939
|
+
let cur = base;
|
|
940
|
+
for (const seg of rel.split(path.sep)) {
|
|
941
|
+
cur = path.join(cur, seg);
|
|
942
|
+
let st;
|
|
943
|
+
try { st = fs.lstatSync(cur); } catch { return true; }
|
|
944
|
+
if (st.isSymbolicLink()) return false;
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
const realRoot = fs.realpathSync(base);
|
|
948
|
+
const real = fs.realpathSync(path.resolve(target));
|
|
949
|
+
return real === realRoot || real.startsWith(realRoot + path.sep);
|
|
950
|
+
} catch { return false; }
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Every project folder a node lockfile names: npm workspace keys and link
|
|
954
|
+
// targets, pnpm importers and link: versions. A package tool follows these,
|
|
955
|
+
// so each must stay inside the repo before the tool runs.
|
|
956
|
+
function projectFolders(tool, dir, text) {
|
|
957
|
+
const out = [];
|
|
958
|
+
if (tool === 'npm') {
|
|
959
|
+
let json = null;
|
|
960
|
+
try { json = JSON.parse(text); } catch {}
|
|
961
|
+
for (const [key, v] of Object.entries((json && json.packages) || {})) {
|
|
962
|
+
if (key && !key.includes('node_modules/')) out.push(path.join(dir, key));
|
|
963
|
+
if (v && v.link && typeof v.resolved === 'string') out.push(path.join(dir, v.resolved));
|
|
964
|
+
}
|
|
965
|
+
return out;
|
|
966
|
+
}
|
|
967
|
+
if (tool === 'pnpm') {
|
|
968
|
+
let section = '';
|
|
969
|
+
let importer = '.';
|
|
970
|
+
for (const line of text.split('\n')) {
|
|
971
|
+
if (/^\S/.test(line)) { section = line.replace(/:.*$/, '').trim(); importer = '.'; continue; }
|
|
972
|
+
if (section === 'importers' && /^ {2}\S/.test(line)) { importer = unquote(line.trim().replace(/:\s*$/, '')); out.push(path.join(dir, importer)); continue; }
|
|
973
|
+
const m = line.match(/link:([^\s'"]+)/);
|
|
974
|
+
if (m) out.push(path.join(dir, section === 'importers' ? importer : '.', m[1]));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return out;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// ---- what the tool changed
|
|
981
|
+
|
|
982
|
+
function npmPackages(text) {
|
|
983
|
+
try { const j = JSON.parse(text); return (j && j.packages) || null; } catch { return null; }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// npm: every copy reachable from the project folders without passing
|
|
987
|
+
// through a blocked copy, with the copy each of its dependencies resolves to.
|
|
988
|
+
function npmSafeSet(packages, blocked) {
|
|
989
|
+
const seen = new Map();
|
|
990
|
+
const stack = Object.keys(packages).filter((k) => !k.includes('node_modules/'));
|
|
991
|
+
while (stack.length) {
|
|
992
|
+
const key = stack.pop();
|
|
993
|
+
if (seen.has(key)) continue;
|
|
994
|
+
const v = packages[key] || {};
|
|
995
|
+
const isProject = !key.includes('node_modules/');
|
|
996
|
+
const deps = Object.keys({ ...(v.dependencies || {}), ...(v.optionalDependencies || {}), ...(v.peerDependencies || {}), ...(isProject ? (v.devDependencies || {}) : {}) }).sort();
|
|
997
|
+
const kids = [];
|
|
998
|
+
for (const name of deps) {
|
|
999
|
+
const at = npmResolve(packages, key, name);
|
|
1000
|
+
kids.push(`${name}=${at || '-'}`);
|
|
1001
|
+
if (at && !blocked(at)) stack.push(at);
|
|
1002
|
+
}
|
|
1003
|
+
seen.set(key, `${isProject ? '' : `${npmEntryName(packages, key)}@${v.version}`}|${kids.join(',')}`);
|
|
1004
|
+
}
|
|
1005
|
+
return seen;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// pnpm and the rest: name@version nodes. Graph edges come from pnpm only.
|
|
1009
|
+
function keySafeSet(edges, keys, blocked) {
|
|
1010
|
+
if (!edges) return new Map([...keys].filter((k) => !blocked(k)).map((k) => [k, '']));
|
|
1011
|
+
const kids = new Map();
|
|
1012
|
+
const from = (f) => (f.project !== undefined ? `project:${f.project}` : `${f.name}@${f.version}`);
|
|
1013
|
+
for (const e of edges) {
|
|
1014
|
+
const id = from(e.from);
|
|
1015
|
+
if (!kids.has(id)) kids.set(id, []);
|
|
1016
|
+
kids.get(id).push(`${e.name}@${e.version}`);
|
|
1017
|
+
}
|
|
1018
|
+
const seen = new Map();
|
|
1019
|
+
const stack = [...kids.keys()].filter((k) => k.startsWith('project:'));
|
|
1020
|
+
while (stack.length) {
|
|
1021
|
+
const id = stack.pop();
|
|
1022
|
+
if (seen.has(id)) continue;
|
|
1023
|
+
const list = [...new Set(kids.get(id) || [])].sort();
|
|
1024
|
+
seen.set(id, list.join(','));
|
|
1025
|
+
for (const k of list) if (!blocked(k)) stack.push(k);
|
|
1026
|
+
}
|
|
1027
|
+
// A blocked child shows by name only: its version is allowed to move.
|
|
1028
|
+
for (const [id, sig] of seen) seen.set(id, sig.split(',').filter(Boolean).map((k) => (blocked(k) ? `${k.slice(0, k.lastIndexOf('@'))}@*` : k)).sort().join(','));
|
|
1029
|
+
return seen;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// What the tool changed beyond the affected copies. Returns problems in
|
|
1033
|
+
// plain words; empty means the change stayed on the vulnerable paths. A
|
|
1034
|
+
// copy of an affected package that the tool wrote must be at or above the
|
|
1035
|
+
// fix and on the fix's major line. That way --allow-major is never bypassed.
|
|
1036
|
+
function collateralProblems(lf, beforeText, afterText, items) {
|
|
1037
|
+
const problems = [];
|
|
1038
|
+
const names = new Set(items.map((a) => a.name));
|
|
1039
|
+
const gate = (name, version) => {
|
|
1040
|
+
const ok = items.some((a) => a.name === name && compareVersions(version, a.fixed) >= 0 && !isMajorJump(a.fixed, version));
|
|
1041
|
+
if (!ok) problems.push(`${name} went to ${version}. that is not a fixed version on the line this fix allows`);
|
|
1042
|
+
};
|
|
1043
|
+
if (lf.tool === 'npm') {
|
|
1044
|
+
const before = npmPackages(beforeText);
|
|
1045
|
+
const after = npmPackages(afterText);
|
|
1046
|
+
if (!before || !after) return ['the lockfile could not be read after the update'];
|
|
1047
|
+
const affected = (k, pk) => pk[k] && items.some((a) => a.name === npmEntryName(pk, k) && a.version === String(pk[k].version));
|
|
1048
|
+
const same = (k) => before[k] && after[k] && npmEntryName(before, k) === npmEntryName(after, k) && String(before[k].version) === String(after[k].version);
|
|
1049
|
+
const changedAffectedName = (k) => names.has(npmEntryName(after, k)) && !same(k);
|
|
1050
|
+
const sb = npmSafeSet(before, (k) => affected(k, before));
|
|
1051
|
+
const sa = npmSafeSet(after, (k) => changedAffectedName(k) || affected(k, after));
|
|
1052
|
+
for (const [k, sig] of sb) {
|
|
1053
|
+
if (sa.get(k) === sig) continue;
|
|
1054
|
+
problems.push(k.includes('node_modules/') ? `${npmEntryName(before, k)} ${before[k].version} at ${k} changed` : 'the project dependencies changed');
|
|
1055
|
+
}
|
|
1056
|
+
for (const [k, sig] of sa) if (!sb.has(k)) problems.push(`${npmEntryName(after, k)} ${after[k] && after[k].version} at ${k} is new`);
|
|
1057
|
+
// A copy the tool added must hang under a new fixed copy, not float free.
|
|
1058
|
+
const under = new Set();
|
|
1059
|
+
const stack = Object.keys(after).filter((k) => k.includes('node_modules/') && changedAffectedName(k));
|
|
1060
|
+
while (stack.length) {
|
|
1061
|
+
const k = stack.pop();
|
|
1062
|
+
if (under.has(k)) continue;
|
|
1063
|
+
under.add(k);
|
|
1064
|
+
const v = after[k] || {};
|
|
1065
|
+
for (const name of Object.keys({ ...(v.dependencies || {}), ...(v.optionalDependencies || {}), ...(v.peerDependencies || {}) })) { const at = npmResolve(after, k, name); if (at) stack.push(at); }
|
|
1066
|
+
}
|
|
1067
|
+
for (const k of Object.keys(after)) {
|
|
1068
|
+
if (!k.includes('node_modules/') || same(k) || names.has(npmEntryName(after, k)) || under.has(k)) continue;
|
|
1069
|
+
problems.push(`${npmEntryName(after, k)} ${after[k].version} at ${k} is new`);
|
|
1070
|
+
}
|
|
1071
|
+
for (const k of Object.keys(after)) if (k.includes('node_modules/') && changedAffectedName(k)) gate(npmEntryName(after, k), String(after[k].version));
|
|
1072
|
+
return [...new Set(problems)];
|
|
1073
|
+
}
|
|
1074
|
+
const beforeList = lf.parse(beforeText);
|
|
1075
|
+
const afterList = lf.parse(afterText);
|
|
1076
|
+
const bKeys = new Set(beforeList.map((p) => `${p.name}@${p.version}`));
|
|
1077
|
+
const aKeys = new Set(afterList.map((p) => `${p.name}@${p.version}`));
|
|
1078
|
+
const isAffected = (k) => items.some((a) => k === `${a.name}@${a.version}`);
|
|
1079
|
+
const nameOf = (k) => k.slice(0, k.lastIndexOf('@'));
|
|
1080
|
+
const newAffectedName = (k) => names.has(nameOf(k)) && !bKeys.has(k);
|
|
1081
|
+
const pnpm = lf.tool === 'pnpm';
|
|
1082
|
+
const sb = keySafeSet(pnpm ? pnpmGraph(beforeText) : null, bKeys, isAffected);
|
|
1083
|
+
const sa = keySafeSet(pnpm ? pnpmGraph(afterText) : null, aKeys, (k) => newAffectedName(k) || isAffected(k));
|
|
1084
|
+
for (const [k, sig] of sb) {
|
|
1085
|
+
if (!k.startsWith('project:') && !aKeys.has(k)) problems.push(`${k.replace(/@([^@]+)$/, ' $1')} is gone`);
|
|
1086
|
+
else if (sa.get(k) !== sig) problems.push(k.startsWith('project:') ? 'the project dependencies changed' : `what ${k.replace(/@([^@]+)$/, ' $1')} uses changed`);
|
|
1087
|
+
}
|
|
1088
|
+
for (const k of sa.keys()) if (!sb.has(k) && !k.startsWith('project:')) problems.push(`${k.replace(/@([^@]+)$/, ' $1')} is new`);
|
|
1089
|
+
// A package the tool added must hang under a new fixed copy (pnpm), or
|
|
1090
|
+
// be a new version of an affected package (lockfiles with no graph).
|
|
1091
|
+
const under = new Set();
|
|
1092
|
+
if (pnpm) {
|
|
1093
|
+
const kids = new Map();
|
|
1094
|
+
for (const e of pnpmGraph(afterText)) if (e.from.project === undefined) { const id = `${e.from.name}@${e.from.version}`; if (!kids.has(id)) kids.set(id, []); kids.get(id).push(`${e.name}@${e.version}`); }
|
|
1095
|
+
const stack = [...aKeys].filter(newAffectedName);
|
|
1096
|
+
while (stack.length) { const k = stack.pop(); if (under.has(k)) continue; under.add(k); for (const c of kids.get(k) || []) stack.push(c); }
|
|
1097
|
+
}
|
|
1098
|
+
for (const k of aKeys) if (!bKeys.has(k) && !names.has(nameOf(k)) && !under.has(k)) problems.push(`${k.replace(/@([^@]+)$/, ' $1')} is new`);
|
|
1099
|
+
for (const k of aKeys) if (newAffectedName(k)) gate(nameOf(k), k.slice(k.lastIndexOf('@') + 1));
|
|
1100
|
+
return [...new Set(problems)];
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Files git sees as changed or new under the repo, as absolute paths.
|
|
1104
|
+
function gitChanged(root, exec) {
|
|
1105
|
+
let top;
|
|
1106
|
+
let out;
|
|
1107
|
+
try {
|
|
1108
|
+
top = String(exec('git', ['rev-parse', '--show-toplevel'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).trim();
|
|
1109
|
+
out = String(exec('git', ['status', '--porcelain', '-z', '--untracked-files=all'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
1110
|
+
} catch { return null; }
|
|
1111
|
+
const map = new Map();
|
|
1112
|
+
const parts2 = out.split('\0');
|
|
1113
|
+
for (let i = 0; i < parts2.length; i++) {
|
|
1114
|
+
const e = parts2[i];
|
|
1115
|
+
if (!e) continue;
|
|
1116
|
+
const code = e.slice(0, 2);
|
|
1117
|
+
map.set(path.join(top, e.slice(3)), code);
|
|
1118
|
+
if (code[0] === 'R' || code[0] === 'C') i += 1;
|
|
1119
|
+
}
|
|
1120
|
+
return map;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const TOOL_COMMANDS = {
|
|
1124
|
+
npm: () => [['npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund']]],
|
|
1125
|
+
pnpm: () => [['pnpm', ['install', '--lockfile-only', '--ignore-scripts']]],
|
|
1126
|
+
yarn: (lockText) => (/^__metadata:/m.test(lockText)
|
|
1127
|
+
? [['yarn', ['install', '--mode', 'update-lockfile']]]
|
|
1128
|
+
: [['yarn', ['install', '--ignore-scripts', '--non-interactive']]]),
|
|
1129
|
+
poetry: (_, items) => [['poetry', ['update', '--lock', ...items.map((a) => a.name)]]],
|
|
1130
|
+
go: (_, items) => [...items.map((a) => ['go', ['get', `${a.name}@v${a.fixed}`]]), ['go', ['mod', 'tidy']]],
|
|
1131
|
+
bundler: (_, items) => [['bundle', ['lock', ...items.flatMap((a) => ['--update', a.name])]]],
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const SIDE_FILES = { go: ['go.mod'], poetry: ['pyproject.toml'], bundler: ['Gemfile'] };
|
|
1135
|
+
|
|
1136
|
+
// Fix the affected items of one lockfile with that lockfile's own tool.
|
|
1137
|
+
// Afterward the lockfile is read again: an affected version still in it is
|
|
1138
|
+
// reported as left. A safe copy that moved undoes the whole change.
|
|
1139
|
+
function fixOneLockfile(root, rel, items, { exec, allowedErrors }) {
|
|
1140
|
+
const lockFile = path.join(root, rel);
|
|
1141
|
+
const dir = path.dirname(lockFile);
|
|
1142
|
+
const lf = LOCKFILE_BY_NAME.get(path.basename(rel));
|
|
1143
|
+
const tool = lf.tool;
|
|
1144
|
+
const before = readMaybe(lockFile) || '';
|
|
1145
|
+
const beforeList = lf.parse(before);
|
|
1146
|
+
const left = [];
|
|
1147
|
+
const changed = [];
|
|
1148
|
+
// Before any write: every file this step may write or restore plus every
|
|
1149
|
+
// folder above it up to the repo root, must be a plain file or folder
|
|
1150
|
+
// inside the repo. One that is not refuses the whole lockfile.
|
|
1151
|
+
const pkgFile = path.join(dir, 'package.json');
|
|
1152
|
+
const mayWrite = [lockFile, pkgFile, ...(SIDE_FILES[tool] || []).map((f) => path.join(dir, f))];
|
|
1153
|
+
if (tool === 'npm' || tool === 'pnpm') for (const d of projectFolders(tool, dir, before)) mayWrite.push(d, path.join(d, 'package.json'));
|
|
1154
|
+
const unsafe = mayWrite.filter((f) => !insideRepo(root, f));
|
|
1155
|
+
if (unsafe.length) {
|
|
1156
|
+
const where = path.relative(root, unsafe[0]) || unsafe[0];
|
|
1157
|
+
return { left: items.map((a) => ({ a, why: `${rel} would write through a symlink or outside this repo (${where}). nothing was changed` })), changed: [], refused: true };
|
|
1158
|
+
}
|
|
1159
|
+
if (tool === 'pip') {
|
|
1160
|
+
const r = rewriteRequirements(before, items);
|
|
1161
|
+
if (r.changed) { safeWrite(root, lockFile, r.text); changed.push(rel); }
|
|
1162
|
+
const after = parseRequirements(r.text);
|
|
1163
|
+
for (const a of items) if (after.some((p) => p.name.toLowerCase() === a.name.toLowerCase() && p.version === a.version)) left.push({ a, why: 'its pin could not be rewritten' });
|
|
1164
|
+
return { left, changed };
|
|
1165
|
+
}
|
|
1166
|
+
// Checked before anything is written or run: every folder this lockfile
|
|
1167
|
+
// names stays inside the repo with no symlink on the way.
|
|
1168
|
+
const outside = projectFolders(tool, dir, before).filter((f) => !insideRepo(root, f));
|
|
1169
|
+
if (outside.length) {
|
|
1170
|
+
const where = path.relative(root, outside[0]) || outside[0];
|
|
1171
|
+
return { left: items.map((a) => ({ a, why: `${rel} names a folder outside this repo or behind a symlink (${where}). nothing was changed` })), changed: [], refused: true };
|
|
1172
|
+
}
|
|
1173
|
+
const snap = snapshot(root, [lockFile, pkgFile, ...(SIDE_FILES[tool] || []).map((f) => path.join(dir, f))]);
|
|
1174
|
+
const restore = () => snap.restore();
|
|
1175
|
+
let todo = items;
|
|
1176
|
+
if (tool === 'npm' || tool === 'pnpm') {
|
|
1177
|
+
let plan;
|
|
1178
|
+
try { plan = planNodeEdits(tool, dir, before, items, { allCopies: beforeList, snap, root }); } catch (e) { restore(); return { left: items.map((a) => ({ a, why: `could not plan the change: ${String(e.message || e).split('\n')[0]}` })), changed: [] }; }
|
|
1179
|
+
for (const l of plan.left) left.push(l);
|
|
1180
|
+
todo = items.filter((a) => !plan.left.some((l) => l.a === a));
|
|
1181
|
+
if (!todo.length) { restore(); return { left, changed: [] }; }
|
|
1182
|
+
} else if (tool === 'yarn') {
|
|
1183
|
+
// yarn resolutions cannot be scoped to one parent the way npm and pnpm
|
|
1184
|
+
// overrides can. A plain resolution is safe only when every copy of the
|
|
1185
|
+
// package in the lockfile is the affected version.
|
|
1186
|
+
const pkg = readJson(pkgFile);
|
|
1187
|
+
if (!pkg) { restore(); return { left: items.map((a) => ({ a, why: `there is no package.json next to ${rel}` })), changed: [] }; }
|
|
1188
|
+
const ok = [];
|
|
1189
|
+
for (const a of items) {
|
|
1190
|
+
const versions = versionsOf(beforeList, a.name);
|
|
1191
|
+
if (versions.size !== 1) { left.push({ a, why: 'yarn cannot limit the change to this one copy. other copies of it are safe and must not move' }); continue; }
|
|
1192
|
+
pkg.resolutions = pkg.resolutions || {};
|
|
1193
|
+
pkg.resolutions[a.name] = `^${a.fixed}`;
|
|
1194
|
+
raiseDirect(pkg, a.name, a.fixed);
|
|
1195
|
+
ok.push(a);
|
|
1196
|
+
}
|
|
1197
|
+
if (!ok.length) { restore(); return { left, changed: [] }; }
|
|
1198
|
+
writeJson(root, pkgFile, pkg);
|
|
1199
|
+
todo = ok;
|
|
1200
|
+
}
|
|
1201
|
+
for (const f of snap.files()) if (!insideRepo(root, f)) { restore(); return { left: todo.map((a) => ({ a, why: `a file this fix would write is outside this repo (${f}). nothing was changed` })).concat(left), changed: [], refused: true }; }
|
|
1202
|
+
const statusBefore = gitChanged(root, exec);
|
|
1203
|
+
try {
|
|
1204
|
+
for (const [cmd, args] of TOOL_COMMANDS[tool](before, todo)) exec(cmd, args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
restore();
|
|
1207
|
+
const te = toolError(tool === 'bundler' ? 'bundle' : tool, rel, e, { exec, cwd: dir });
|
|
1208
|
+
allowedErrors.push(te);
|
|
1209
|
+
for (const a of todo) left.push({ a, why: `${te.tool} could not update ${rel}` });
|
|
1210
|
+
return { left, changed: [] };
|
|
1211
|
+
}
|
|
1212
|
+
// The tool may change only the files this step planned. Anything else it
|
|
1213
|
+
// wrote in the repo is put back and the step fails.
|
|
1214
|
+
const statusAfter = gitChanged(root, exec);
|
|
1215
|
+
const real = (f) => { try { return path.join(fs.realpathSync(path.dirname(f)), path.basename(f)); } catch { return path.resolve(f); } };
|
|
1216
|
+
const planned = new Set(snap.files().map(real));
|
|
1217
|
+
let realRoot = root;
|
|
1218
|
+
try { realRoot = fs.realpathSync(root); } catch {}
|
|
1219
|
+
const stray = [];
|
|
1220
|
+
if (!statusBefore || !statusAfter) stray.push('git could not list the changed files');
|
|
1221
|
+
else for (const [f, code] of statusAfter) if (!planned.has(f) && statusBefore.get(f) !== code) stray.push(f);
|
|
1222
|
+
if (stray.length) {
|
|
1223
|
+
restore();
|
|
1224
|
+
for (const f of stray) {
|
|
1225
|
+
if (!path.isAbsolute(f)) continue;
|
|
1226
|
+
try {
|
|
1227
|
+
if (statusAfter.get(f) === '??') fs.rmSync(f, { force: true });
|
|
1228
|
+
else exec('git', ['checkout', '--', f], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1229
|
+
} catch {}
|
|
1230
|
+
}
|
|
1231
|
+
const first = path.isAbsolute(stray[0]) ? path.relative(realRoot, stray[0]) : stray[0];
|
|
1232
|
+
for (const a of todo) left.push({ a, why: `${lf.tool} also changed ${first}${stray.length > 1 ? ` and ${stray.length - 1} more` : ''}. it was undone` });
|
|
1233
|
+
return { left, changed: [] };
|
|
1234
|
+
}
|
|
1235
|
+
const after = readMaybe(lockFile) || '';
|
|
1236
|
+
const afterList = lf.parse(after);
|
|
1237
|
+
// Only the affected copies, along their vulnerable paths, may move.
|
|
1238
|
+
const problems = collateralProblems(lf, before, after, todo);
|
|
1239
|
+
if (problems.length) {
|
|
1240
|
+
restore();
|
|
1241
|
+
for (const a of todo) left.push({ a, why: `the update also changed more than the affected copy: ${problems.slice(0, 2).join('. ')}${problems.length > 2 ? ` and ${problems.length - 2} more` : ''}. it was undone` });
|
|
1242
|
+
return { left, changed: [] };
|
|
1243
|
+
}
|
|
1244
|
+
for (const a of todo) {
|
|
1245
|
+
if (afterList.some((p) => p.name === a.name && p.version === a.version)) left.push({ a, why: `${rel} still holds it after the update` });
|
|
1246
|
+
}
|
|
1247
|
+
for (const f of snap.changed()) changed.push(path.relative(root, f));
|
|
1248
|
+
return { left, changed };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Updates affected packages on a new branch, one lockfile at a time with
|
|
1252
|
+
// that lockfile's own tool. Everything it could not fix is listed in
|
|
1253
|
+
// `manual` with the reason. The list is never partial.
|
|
1254
|
+
function applyFix(root, result, { run, now = new Date(), log = () => {}, allowMajor = false } = {}) {
|
|
1255
|
+
const exec = execFor(run);
|
|
1256
|
+
const manual = [];
|
|
1257
|
+
for (const a of result.affected) if (!a.fixed) manual.push({ ...a, why: 'no fixed version is published yet' });
|
|
1258
|
+
const fixable = [];
|
|
1259
|
+
for (const a of result.affected) {
|
|
1260
|
+
if (!a.fixed) continue;
|
|
1261
|
+
if (a.major && !allowMajor) manual.push({ ...a, why: `${a.fixed} is a new major version. check your code then rerun with --allow-major` });
|
|
1262
|
+
else fixable.push(a);
|
|
1263
|
+
}
|
|
1264
|
+
if (!fixable.length) return { ok: true, changed: 0, manual, toolErrors: [], message: manual.length ? 'nothing could be updated without a person' : 'nothing has a fixed version yet' };
|
|
449
1265
|
let status;
|
|
450
|
-
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' }; }
|
|
451
|
-
if (status) return { ok: false, reason: 'this folder has uncommitted changes. commit or stash them first so the fix branch holds only the update' };
|
|
1266
|
+
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', manual, toolErrors: [] }; }
|
|
1267
|
+
if (status) return { ok: false, reason: 'this folder has uncommitted changes. commit or stash them first so the fix branch holds only the update', manual, toolErrors: [] };
|
|
452
1268
|
let from = '';
|
|
453
1269
|
try { from = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { run }); } catch {}
|
|
454
1270
|
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
|
|
455
1271
|
const branch = `sentinel/fix-dependencies-${stamp}`;
|
|
456
1272
|
git(root, ['checkout', '-b', branch], { run });
|
|
457
1273
|
const changedFiles = new Set();
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
try { pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8')); } catch {}
|
|
471
|
-
if (!pkg) manual.push(...npm);
|
|
472
|
-
else {
|
|
473
|
-
const declared = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.optionalDependencies || {}) };
|
|
474
|
-
const direct = npm.filter((a) => declared[a.name] && a.direct !== false);
|
|
475
|
-
const transitive = npm.filter((a) => !direct.includes(a));
|
|
476
|
-
if (transitive.length) {
|
|
477
|
-
pkg.overrides = pkg.overrides || {};
|
|
478
|
-
for (const a of transitive) {
|
|
479
|
-
const cur = pkg.overrides[a.name];
|
|
480
|
-
if (typeof cur === 'string' && compareVersions(cur.replace(/^[^0-9]*/, ''), a.fixed) >= 0) continue;
|
|
481
|
-
if (cur && typeof cur !== 'string') { manual.push(a); continue; }
|
|
482
|
-
pkg.overrides[a.name] = `^${a.fixed}`;
|
|
483
|
-
}
|
|
484
|
-
fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
485
|
-
}
|
|
486
|
-
const { execFileSync } = require('child_process');
|
|
487
|
-
const exec = run || ((cmd, a, o) => execFileSync(cmd, a, o));
|
|
488
|
-
try {
|
|
489
|
-
// --ignore-scripts: no install script runs while fixing a security issue.
|
|
490
|
-
const specs = direct.map((a) => `${a.name}@^${a.fixed}`);
|
|
491
|
-
if (specs.length) exec('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', ...specs], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
492
|
-
else exec('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
493
|
-
changedFiles.add('package.json');
|
|
494
|
-
changedFiles.add('package-lock.json');
|
|
495
|
-
} catch (e) {
|
|
496
|
-
log(`npm could not update the packages: ${String(e.message || e).split('\n')[0]}`);
|
|
497
|
-
// Leave no half update: put package.json and the lockfile back.
|
|
498
|
-
try { git(root, ['checkout', '--', 'package.json', 'package-lock.json'], { run }); } catch {}
|
|
499
|
-
manual.push(...npm);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
1274
|
+
const toolErrors = [];
|
|
1275
|
+
const byLock = new Map();
|
|
1276
|
+
for (const a of fixable) {
|
|
1277
|
+
if (!a.lockfile || !LOCKFILE_BY_NAME.has(path.basename(a.lockfile))) { manual.push({ ...a, why: 'it is not in a lockfile this fix can read' }); continue; }
|
|
1278
|
+
if (!byLock.has(a.lockfile)) byLock.set(a.lockfile, []);
|
|
1279
|
+
byLock.get(a.lockfile).push(a);
|
|
1280
|
+
}
|
|
1281
|
+
for (const [rel, items] of byLock) {
|
|
1282
|
+
let r;
|
|
1283
|
+
try { r = fixOneLockfile(root, rel, items, { exec, allowedErrors: toolErrors }); } catch (e) { r = { left: items.map((a) => ({ a, why: `the fix stopped: ${String(e.message || e).split('\n')[0]}` })), changed: [] }; }
|
|
1284
|
+
for (const l of r.left) manual.push({ ...l.a, why: l.why });
|
|
1285
|
+
for (const f of r.changed) changedFiles.add(f);
|
|
502
1286
|
}
|
|
1287
|
+
for (const te of toolErrors) log(`${te.tool} could not update ${te.lockfile}: ${te.lines.join('. ')}`);
|
|
503
1288
|
let committed = false;
|
|
504
1289
|
const dirty = git(root, ['status', '--porcelain'], { run });
|
|
505
|
-
if (dirty) {
|
|
1290
|
+
if (dirty && changedFiles.size) {
|
|
506
1291
|
git(root, ['add', '-A', '--', ...[...changedFiles].filter((f) => fs.existsSync(path.join(root, f)))], { run });
|
|
507
1292
|
try {
|
|
508
1293
|
git(root, ['commit', '-m', 'Update dependencies with known security fixes\n\nMade by atris sentinel watch --fix. Review before merging.'], { run });
|
|
509
1294
|
} catch (e) {
|
|
510
|
-
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 };
|
|
1295
|
+
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, toolErrors };
|
|
511
1296
|
}
|
|
512
1297
|
committed = true;
|
|
513
1298
|
} else {
|
|
514
1299
|
// Nothing changed: drop the empty branch and go back.
|
|
515
1300
|
try { if (from) git(root, ['checkout', from], { run }); git(root, ['branch', '-D', branch], { run }); } catch {}
|
|
516
1301
|
}
|
|
517
|
-
return { ok: true, branch: committed ? branch : null, from, changed: committed ? changedFiles.size : 0, manual };
|
|
1302
|
+
return { ok: true, branch: committed ? branch : null, from, changed: committed ? changedFiles.size : 0, manual, toolErrors };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ---------------------------------------------------------------- branch
|
|
1306
|
+
|
|
1307
|
+
function gitTry(root, args, run) {
|
|
1308
|
+
try { return git(root, args, { run }); } catch { return null; }
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Which branch this run is on and whether it is the repo's default branch.
|
|
1312
|
+
// When it is not, liveFingerprints holds every package version still in the
|
|
1313
|
+
// default branch's lockfiles. A threat counts as fixed only when the fix
|
|
1314
|
+
// has reached that branch.
|
|
1315
|
+
function branchState(root, { run } = {}) {
|
|
1316
|
+
if (gitTry(root, ['rev-parse', '--is-inside-work-tree'], run) !== 'true') return { git: false, onDefault: true };
|
|
1317
|
+
const branch = gitTry(root, ['rev-parse', '--abbrev-ref', 'HEAD'], run) || 'HEAD';
|
|
1318
|
+
let defaultBranch = null;
|
|
1319
|
+
const originHead = gitTry(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], run);
|
|
1320
|
+
if (originHead && originHead.startsWith('origin/')) defaultBranch = originHead.slice('origin/'.length);
|
|
1321
|
+
if (!defaultBranch) {
|
|
1322
|
+
for (const b of ['main', 'master']) if (gitTry(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${b}`], run)) { defaultBranch = b; break; }
|
|
1323
|
+
}
|
|
1324
|
+
if (!defaultBranch) {
|
|
1325
|
+
for (const b of ['main', 'master']) if (gitTry(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`], run)) { defaultBranch = b; break; }
|
|
1326
|
+
}
|
|
1327
|
+
// One branch and no remote: that branch is the default.
|
|
1328
|
+
if (!defaultBranch) defaultBranch = branch !== 'HEAD' ? branch : null;
|
|
1329
|
+
const onDefault = Boolean(defaultBranch) && branch === defaultBranch;
|
|
1330
|
+
const state = { git: true, branch, defaultBranch, onDefault };
|
|
1331
|
+
if (onDefault || !defaultBranch) return state;
|
|
1332
|
+
const ref = gitTry(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${defaultBranch}`], run) ? `origin/${defaultBranch}` : defaultBranch;
|
|
1333
|
+
state.defaultRef = ref;
|
|
1334
|
+
const listing = gitTry(root, ['ls-tree', '-r', '--name-only', ref, '--', '.'], run);
|
|
1335
|
+
if (listing == null) { state.liveFingerprints = null; return state; }
|
|
1336
|
+
const prefix = gitTry(root, ['rev-parse', '--show-prefix'], run) || '';
|
|
1337
|
+
const files = [];
|
|
1338
|
+
for (const full of listing.split('\n').filter(Boolean)) {
|
|
1339
|
+
const rel = prefix && full.startsWith(prefix) ? full.slice(prefix.length) : full;
|
|
1340
|
+
if (!LOCKFILE_BY_NAME.has(path.basename(rel))) continue;
|
|
1341
|
+
const segs = rel.split('/').slice(0, -1);
|
|
1342
|
+
if (segs.some(skipDir) || segs.length > MAX_DEPTH) continue;
|
|
1343
|
+
// Fail closed: a default branch we cannot read in full keeps every
|
|
1344
|
+
// gone threat open.
|
|
1345
|
+
if (files.length >= MAX_LOCKFILES * 2) { state.liveFingerprints = null; return state; }
|
|
1346
|
+
const size = Number(gitTry(root, ['cat-file', '-s', `${ref}:./${rel}`], run));
|
|
1347
|
+
if (!Number.isFinite(size) || size > MAX_LOCKFILE_BYTES) { state.liveFingerprints = null; return state; }
|
|
1348
|
+
const text = gitTry(root, ['show', `${ref}:./${rel}`], run);
|
|
1349
|
+
if (text == null) { state.liveFingerprints = null; return state; }
|
|
1350
|
+
files.push({ file: rel, text });
|
|
1351
|
+
}
|
|
1352
|
+
state.liveFingerprints = fingerprintsIn(files);
|
|
1353
|
+
return state;
|
|
518
1354
|
}
|
|
519
1355
|
|
|
520
1356
|
module.exports = {
|
|
521
1357
|
OSV_BASE,
|
|
1358
|
+
MAX_LOCKFILES,
|
|
522
1359
|
parsePackageLock,
|
|
523
1360
|
parsePnpmLock,
|
|
524
1361
|
parseYarnLock,
|
|
@@ -526,10 +1363,15 @@ module.exports = {
|
|
|
526
1363
|
parsePoetryLock,
|
|
527
1364
|
parseGoSum,
|
|
528
1365
|
parseGemfileLock,
|
|
1366
|
+
pnpmGraph,
|
|
529
1367
|
compareVersions,
|
|
1368
|
+
isMajorJump,
|
|
530
1369
|
fixedVersionFor,
|
|
531
1370
|
runWatch,
|
|
532
1371
|
toEvents,
|
|
1372
|
+
lockfilesByFingerprint,
|
|
533
1373
|
renderReport,
|
|
534
1374
|
applyFix,
|
|
1375
|
+
branchState,
|
|
1376
|
+
toolErrorLines,
|
|
535
1377
|
};
|