atris 3.82.7 → 3.82.9

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.
@@ -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(/^ {2}['"]?\/?((?:@[^/@\s]+\/)?[^/@\s(]+)[@/]([0-9][^:('"\s]*)(?:\([^:]*\))?['"]?:\s*$/);
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 readLockfiles(root) {
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 seen = new Set();
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 lf of LOCKFILES) {
177
- const file = path.join(root, lf.file);
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
- found.push({ file: lf.file, packages: list.length });
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 = `${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 });
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: found, packages, skipped };
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
- const result = { root: path.resolve(root), lockfiles, packages: packages.length, skipped, affected: [], warnings: [] };
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
- result.affected.push({ ...pkg, advisories: vids, fixed, malicious, severity, unfixed: fixes.length < vids.length });
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
- function threatDetail(a) {
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 head.slice(0, 1000);
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
- return result.affected.map((a) => events.makeEvent({
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: events.fingerprint('threat', a.ecosystem, a.name, a.version),
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
- L.push(` sentinel watch: checked ${result.packages} package versions from ${files} against the osv database`);
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} (${a.ecosystem}): ${what}. ${fix}`);
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 fixable = result.affected.filter((a) => a.fixed).length;
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(` ${result.affected.length} affected package${result.affected.length === 1 ? '' : 's'}. ${fixable} ha${fixable === 1 ? 's' : 've'} a fixed version`);
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 git(root, args, { run } = {}) {
584
+ function execFor(run) {
423
585
  const { execFileSync } = require('child_process');
424
- const exec = run || ((cmd, a, o) => execFileSync(cmd, a, o));
425
- return String(exec('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) || '').trim();
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
- // Updates affected packages on a new branch. npm packages go through npm
443
- // (direct ones by version, the rest by an override in package.json);
444
- // requirements.txt pins are rewritten. Everything else is listed for a
445
- // person to update.
446
- function applyFix(root, result, { run, now = new Date(), log = () => {} } = {}) {
447
- const fixable = result.affected.filter((a) => a.fixed);
448
- if (!fixable.length) return { ok: true, changed: 0, manual: [], message: 'nothing has a fixed version yet' };
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 manual = [];
459
- const npm = fixable.filter((a) => a.ecosystem === 'npm' && a.lockfile === 'package-lock.json');
460
- const py = fixable.filter((a) => a.ecosystem === 'PyPI' && a.lockfile === 'requirements.txt');
461
- for (const a of fixable) if (!npm.includes(a) && !py.includes(a)) manual.push(a);
462
- if (py.length) {
463
- const file = path.join(root, 'requirements.txt');
464
- const r = rewriteRequirements(fs.readFileSync(file, 'utf8'), py);
465
- if (r.changed) { fs.writeFileSync(file, r.text); changedFiles.add('requirements.txt'); }
466
- }
467
- if (npm.length) {
468
- const pkgFile = path.join(root, 'package.json');
469
- let pkg = null;
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
  };