nativekeel 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Health check for React Native and Expo apps. One command, no install, no account
6
6
  npx nativekeel
7
7
  ```
8
8
 
9
- **What's new in 0.1.16:** deep-link checks: unverified Android App Links, and the template `myapp://` scheme that other apps can claim (MASVS-PLATFORM). **0.1.15:** run it at a monorepo root and it lists the app folders to scan. **0.1.14:** secrets in Expo config (`extra`, `EXPO_PUBLIC_`, `eas.json`) and imports of APIs removed from React Native core. Recent releases added a *Start here* list in every report, hacking risks mapped to OWASP MASVS, crash and performance checks. Every check is tuned on 450+ open-source React Native and Expo apps to keep false alarms out. [All releases →](https://nativekeel.com/changelog)
9
+ **What's new in 0.1.18:** the upgrade plan only suggests library versions your current React Native supports, moves React Navigation and RN Firebase packages together, and says plainly when a vulnerable package has no fixed release. **0.1.17:** every vulnerable package shows the version that fixes it; Expo apps get one line for the packages the SDK upgrade moves. Recent releases added a *Start here* list in every report, hacking risks mapped to OWASP MASVS, crash and performance checks. Every check is tuned on 450+ open-source React Native and Expo apps to keep false alarms out. [All releases →](https://nativekeel.com/changelog)
10
10
 
11
11
  ```
12
12
  Summary 4 critical · 5 high · 4 medium · 6 low
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Health check for React Native and Expo apps: upgrade blockers, store deadlines, crash risks, vulnerable dependencies and leaked secrets, with a step-by-step upgrade plan.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/analyze.js CHANGED
@@ -8,6 +8,7 @@ import { stabilityChecks } from './stability.js';
8
8
  import { performanceChecks } from './performance.js';
9
9
  import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, reverseEngineering, expoConfigSecrets, deepLinks, masvsOf, MASVS_GROUPS } from './security.js';
10
10
  import { lockedVersions } from './lockfile.js';
11
+ import { expoPinnedNames, movesWithExpoSdk } from './expo-pins.js';
11
12
  import { findUnused, filesImportingWith } from './usage.js';
12
13
  import { matchKnownIssues, compareVersions } from './known-issues.js';
13
14
  import { COMPAT, bestRange, checkCompat } from './compat.js';
@@ -54,6 +55,11 @@ function isNativeModule(root, name, dir) {
54
55
  return false;
55
56
  }
56
57
  }
58
+ // Known without looking: React Navigation is JavaScript only (the directory reports its
59
+ // monorepo as native), and the renamed community packages all contain native code (some
60
+ // were removed from the directory).
61
+ if (/^@react-navigation\//.test(name)) return false;
62
+ if (RENAMED[name]) return true;
57
63
  // Without node_modules, ask React Native Directory. Its ios/android flags mean "works on",
58
64
  // not "has native code" (pure JS packages set them too), so prefer its hasNativeCode.
59
65
  if (dir && dir.github && typeof dir.github.hasNativeCode === 'boolean') return dir.github.hasNativeCode;
@@ -232,6 +238,8 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
232
238
  const codegen = forcedOffline ? {} : await registry.codegenSupport(archSuspects.map((n) => ({ name: n, version: versionOf(n) })));
233
239
 
234
240
  const deps = [];
241
+ let pinnedCache = null;
242
+ const expoPinned = () => (pinnedCache ||= expoPinnedNames(project.root));
235
243
  for (const name of names) {
236
244
  const dir = directory[name];
237
245
  const version = versionOf(name);
@@ -296,13 +304,15 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
296
304
  });
297
305
  }
298
306
  if (!noNewArch && !dep.unmaintained && !stale && version && dep.latest && lineOf(version) !== lineOf(dep.latest)) {
307
+ // In Expo apps the SDK decides these versions: the SDK upgrade moves them, no separate work.
308
+ const expoManaged = !!project.expoVersion && movesWithExpoSdk(name, expoPinned());
299
309
  add({
300
310
  id: `dep-major:${name}`,
301
- severity: 'low',
311
+ severity: expoManaged ? 'info' : 'low',
302
312
  area: 'dependency',
303
313
  title: `${name} ${version} → ${dep.latest}`,
304
- detail: 'Major version behind.',
305
- fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native },
314
+ detail: expoManaged ? 'Major version behind; the Expo SDK upgrade moves it.' : 'Major version behind.',
315
+ fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native, expoManaged },
306
316
  });
307
317
  }
308
318
  }
@@ -421,7 +431,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
421
431
  const v = installedVersion(project.root, name) || locked[name];
422
432
  if (v && /^\d+\.\d+\.\d+/.test(v)) exact[name] = v;
423
433
  }
424
- const vulns = await vulnerableDependencies(registry, exact);
434
+ const vulns = await vulnerableDependencies(registry, exact, latest);
425
435
  if (vulns === null) net.failed++;
426
436
  for (const f of vulns || []) add(f);
427
437
  }
package/src/compat.js CHANGED
@@ -108,6 +108,8 @@ const rnLabel = (row) => (row.rnMax ? `0.${row.rnMin}–0.${row.rnMax}` : `0.${r
108
108
  function versionLabel(row) {
109
109
  const from = row.from.replace(/\.0$/, '');
110
110
  if (!row.to) return `${from}+`;
111
+ // "2.99.99" means the rest of that major line.
112
+ if (/^\d+\.99\.99$/.test(row.to)) return `${from}+ (${row.to.split('.')[0]}.x line)`;
111
113
  const to = row.to.replace(/\.99$/, '');
112
114
  return from === to ? `${from}.x` : `${from}–${to}.x`;
113
115
  }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Libraries whose version an Expo SDK decides (from Expo's bundledNativeModules.json). When the
5
+ // app's own copy of that file is installed it is used instead; this list covers scans without
6
+ // node_modules.
7
+ const EXPO_PINNED = new Set([
8
+ 'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', 'react-native-screens',
9
+ 'react-native-safe-area-context', 'react-native-webview', 'react-native-svg', 'react-native-pager-view',
10
+ 'react-native-maps', 'react-native-view-shot', 'react-native-get-random-values', 'react-native-keyboard-controller',
11
+ '@react-native-async-storage/async-storage', '@react-native-community/datetimepicker', '@react-native-community/slider',
12
+ '@react-native-community/netinfo', '@react-native-picker/picker', '@react-native-masked-view/masked-view',
13
+ 'lottie-react-native', '@shopify/flash-list', '@shopify/react-native-skia', '@stripe/stripe-react-native',
14
+ ]);
15
+
16
+ export function expoPinnedNames(root) {
17
+ try {
18
+ let dir = root;
19
+ while (true) {
20
+ const file = path.join(dir, 'node_modules', 'expo', 'bundledNativeModules.json');
21
+ if (fs.existsSync(file)) return new Set(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))));
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir) return EXPO_PINNED;
24
+ dir = parent;
25
+ }
26
+ } catch {
27
+ return EXPO_PINNED;
28
+ }
29
+ }
30
+
31
+ // Packages an Expo SDK upgrade moves on its own (`npx expo install --fix`): no separate work.
32
+ export function movesWithExpoSdk(name, pinned) {
33
+ return /^(expo-|@expo\/)/.test(name) || ['expo', 'jest-expo', 'react-native', 'react', 'react-dom'].includes(name) || pinned.has(name);
34
+ }
package/src/plan.js CHANGED
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import { UPGRADE_HELPER_URL, NEW_ARCH_ONLY_MINOR } from './rules.js';
4
4
  import { SITE_URL } from './config.js';
5
5
  import { COMPAT, bestRange } from './compat.js';
6
+ import { compareVersions } from './known-issues.js';
7
+ import { expoPinnedNames, movesWithExpoSdk } from './expo-pins.js';
6
8
 
7
9
  // Turns a scan result into an ordered upgrade plan. The order matters more than the steps:
8
10
  // stop leaks first, then remove blockers, switch architecture on the current version,
@@ -145,31 +147,27 @@ function safetyNetPhase(tool) {
145
147
  };
146
148
  }
147
149
 
148
- // Libraries whose version an Expo SDK decides (from Expo's bundledNativeModules.json). When the
149
- // app's own copy of that file is installed it is used instead; this list covers scans without
150
- // node_modules.
151
- const EXPO_PINNED = new Set([
152
- 'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', 'react-native-screens',
153
- 'react-native-safe-area-context', 'react-native-webview', 'react-native-svg', 'react-native-pager-view',
154
- 'react-native-maps', 'react-native-view-shot', 'react-native-get-random-values', 'react-native-keyboard-controller',
155
- '@react-native-async-storage/async-storage', '@react-native-community/datetimepicker', '@react-native-community/slider',
156
- '@react-native-community/netinfo', '@react-native-picker/picker', '@react-native-masked-view/masked-view',
157
- 'lottie-react-native', '@shopify/flash-list', '@shopify/react-native-skia', '@stripe/stripe-react-native',
158
- ]);
159
-
160
- function expoPinnedNames(root) {
161
- try {
162
- let dir = root;
163
- while (true) {
164
- const file = path.join(dir, 'node_modules', 'expo', 'bundledNativeModules.json');
165
- if (fs.existsSync(file)) return new Set(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))));
166
- const parent = path.dirname(dir);
167
- if (parent === dir) return EXPO_PINNED;
168
- dir = parent;
150
+ // Package families that must share a major version: updating one alone crashes at runtime.
151
+ const FAMILIES = [
152
+ { re: /^@react-navigation\//, label: 'React Navigation' },
153
+ { re: /^@react-native-firebase\//, label: 'React Native Firebase' },
154
+ ];
155
+ function bumpSteps(list, extra = () => '') {
156
+ const steps = [];
157
+ const done = new Set();
158
+ for (const d of list) {
159
+ if (done.has(d.name)) continue;
160
+ const fam = FAMILIES.find((f) => f.re.test(d.name));
161
+ const members = fam ? list.filter((x) => fam.re.test(x.name)) : [];
162
+ if (members.length > 1) {
163
+ for (const m of members) done.add(m.name);
164
+ steps.push(`${fam.label}, all together in one change (mixed majors crash at runtime): ${members.map((m) => `\`${m.name}\` ${m.from} → ${m.to}${extra(m)}`).join(', ')}.`);
165
+ } else {
166
+ done.add(d.name);
167
+ steps.push(`\`${d.name}\` ${d.from} → ${d.to}${extra(d)}`);
169
168
  }
170
- } catch {
171
- return EXPO_PINNED;
172
169
  }
170
+ return steps;
173
171
  }
174
172
 
175
173
  export function buildPlan(result) {
@@ -178,7 +176,7 @@ export function buildPlan(result) {
178
176
  // In Expo projects `npx expo install` picks the version that matches the SDK.
179
177
  const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
180
178
  const pinned = isExpo ? expoPinnedNames(result.project.root) : new Set();
181
- const movesWithExpo = (name) => isExpo && (/^(expo-|@expo\/)/.test(name) || ['expo', 'jest-expo', 'react-native', 'react', 'react-dom'].includes(name) || pinned.has(name));
179
+ const movesWithExpo = (name) => isExpo && movesWithExpoSdk(name, pinned);
182
180
  const installed = new Set(result.deps.map((d) => d.name));
183
181
  const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
184
182
  const phases = [];
@@ -286,10 +284,13 @@ export function buildPlan(result) {
286
284
  } else if (f.fix.kind === 'ats') {
287
285
  sec.push({ sev, step: `In \`${f.fix.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.` });
288
286
  } else if (f.fix.kind === 'security') {
289
- sec.push({ sev, step: f.fix.step });
287
+ // Hermes gets its own, more detailed step under legacy tooling.
288
+ const step = byKind('enable-hermes').length ? f.fix.step.replace('Turn on Hermes. ', '') : f.fix.step;
289
+ sec.push({ sev, step });
290
290
  } else if (f.fix.kind === 'vuln-dep' && (f.fix.severity === 'critical' || f.fix.severity === 'high')) {
291
291
  const v = f.fix;
292
- sec.push({ sev, step: `Update \`${v.name}\` ${v.from}${v.to ? ` to ${v.to} or later` : ''}: it has ${v.severity} known vulnerabilities. ${movesWithExpo(v.name) ? 'Expo pins it: the SDK upgrade below moves it.' : 'Check its changelog for breaking changes.'}` });
292
+ if (v.noFix) sec.push({ sev, step: `\`${v.name}\` ${v.from} has ${v.severity} known vulnerabilities and no fixed release (the latest is ${v.latest}): replace it, or make sure it never handles untrusted input.` });
293
+ else sec.push({ sev, step: `Update \`${v.name}\` ${v.from}${v.to ? ` to ${v.to}${v.to.startsWith('a release') ? '' : ' or later'}` : ''}: it has ${v.severity} known vulnerabilities. ${movesWithExpo(v.name) ? 'Expo pins it: the SDK upgrade below moves it.' : 'Check its changelog for breaking changes.'}` });
293
294
  }
294
295
  }
295
296
  const securitySteps = sec.sort((a, b) => a.sev - b.sev).map((x) => x.step);
@@ -364,12 +365,26 @@ export function buildPlan(result) {
364
365
  }
365
366
 
366
367
  const expoMoves = byKind('bump-dep').filter((d) => movesWithExpo(d.name));
367
- const nativeBumps = byKind('bump-dep').filter((d) => d.native && !movesWithExpo(d.name));
368
+ // Libraries with a compatibility table: only as far as the current React Native supports.
369
+ // The React Native steps move them further.
370
+ const curMinor = result.project.reactNative ? Number(result.project.reactNative.split('.')[1]) : null;
371
+ const archNow = !byKind('enable-new-arch').length;
372
+ const nativeBumps = byKind('bump-dep')
373
+ .filter((d) => d.native && !movesWithExpo(d.name))
374
+ .map((d) => {
375
+ if (!COMPAT[d.name] || curMinor === null) return d;
376
+ const best = bestRange(d.name, curMinor, archNow);
377
+ if (!best) return d;
378
+ if (compareVersions(d.from, best.from) >= 0 && (!best.to || compareVersions(d.from, best.to) <= 0)) return null;
379
+ if (compareVersions(d.from, best.from) > 0) return null;
380
+ return { ...d, to: best.range, capped: `0.${curMinor}` };
381
+ })
382
+ .filter(Boolean);
368
383
  if (nativeBumps.length) {
369
384
  phases.push({
370
385
  title: 'Update native modules on the current React Native version',
371
386
  why: 'Newer releases of native modules usually add New Architecture support. Pick the newest release that still supports your current React Native version; check each changelog for its minimum.',
372
- steps: nativeBumps.map((d) => `\`${d.name}\` ${d.from} → ${d.to}`),
387
+ steps: bumpSteps(nativeBumps, (d) => (d.capped ? ` (the newest for React Native ${d.capped}; later versions come with the React Native steps below)` : '')),
373
388
  });
374
389
  }
375
390
 
@@ -541,7 +556,7 @@ export function buildPlan(result) {
541
556
  phases.push({
542
557
  title: 'Update JavaScript-only packages',
543
558
  why: 'These do not block the upgrade. Do them last, one major at a time, following each migration guide.',
544
- steps: jsBumps.map((d) => `\`${d.name}\` ${d.from} → ${d.to}`),
559
+ steps: bumpSteps(jsBumps),
545
560
  });
546
561
  }
547
562
 
@@ -5,7 +5,7 @@ import { MASVS_GROUPS } from './security.js';
5
5
 
6
6
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
7
7
  // Turns `code` spans from finding text into <code>.
8
- const rich = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
8
+ const rich = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
9
9
 
10
10
  // A single self-contained file that can be emailed or attached to a ticket.
11
11
  // `brand` replaces NativeKeel branding with an agency's name.
@@ -26,8 +26,8 @@ export function htmlReport(result, { brand = null, plan = null } = {}) {
26
26
  ).join('\n');
27
27
  const bumps = result.findings.filter(isBump);
28
28
  const bumpTable = bumps.length
29
- ? `<details class="bumps"><summary>${bumps.length} package${bumps.length === 1 ? ' is' : 's are'} a major version behind <span class="muted">(low)</span></summary><table><thead><tr><th>Package</th><th>Installed</th><th>Latest</th><th></th></tr></thead><tbody>${bumps
30
- .map((f) => `<tr><td>${esc(f.fix.name)}</td><td>${esc(f.fix.from || '')}</td><td>${esc(f.fix.to || '')}</td><td class="muted">${f.fix.native ? 'native' : ''}</td></tr>`)
29
+ ? `<details class="bumps"><summary>${bumps.length} package${bumps.length === 1 ? ' is' : 's are'} a major version behind${bumps.some((f) => f.fix.expoManaged) ? ` <span class="muted">(${bumps.filter((f) => f.fix.expoManaged).length} move with the Expo SDK upgrade)</span>` : ' <span class="muted">(low)</span>'}</summary><table><thead><tr><th>Package</th><th>Installed</th><th>Latest</th><th></th></tr></thead><tbody>${bumps
30
+ .map((f) => `<tr><td>${esc(f.fix.name)}</td><td>${esc(f.fix.from || '')}</td><td>${esc(f.fix.to || '')}</td><td class="muted">${[f.fix.native && 'native', f.fix.expoManaged && 'moves with Expo SDK'].filter(Boolean).join(', ')}</td></tr>`)
31
31
  .join('')}</tbody></table></details>`
32
32
  : '';
33
33
 
@@ -50,10 +50,17 @@ export function textReport(result, { color = true, verbose = false } = {}) {
50
50
  out.push('');
51
51
  continue;
52
52
  }
53
- for (const f of group) {
53
+ // Packages the Expo SDK upgrade moves: one line, they are not separate work.
54
+ const expoMoves = group.filter((f) => f.fix && f.fix.expoManaged);
55
+ for (const f of group.filter((x) => !(x.fix && x.fix.expoManaged))) {
54
56
  out.push(`${label[sev]} ${c.bold(f.title)}`);
55
57
  out.push(c.gray(indent + f.detail));
56
58
  }
59
+ if (expoMoves.length) {
60
+ const names = expoMoves.map((f) => f.fix.name);
61
+ out.push(`${label[sev]} ${c.bold(`${expoMoves.length} package${expoMoves.length === 1 ? '' : 's'} move with the Expo SDK upgrade`)}`);
62
+ out.push(c.gray(`${indent}${(verbose ? names : names.slice(0, 6)).join(', ')}${!verbose && names.length > 6 ? `, … (${names.length - 6} more with --verbose)` : ''}. \`npx expo install --fix\` sets the versions the new SDK expects.`));
63
+ }
57
64
  out.push('');
58
65
  }
59
66
 
package/src/security.js CHANGED
@@ -33,12 +33,12 @@ const read = (file) => {
33
33
  }
34
34
  };
35
35
 
36
- const SERVER_ONLY = /node\.?js|server[- ]side|\bhttp adapter\b|\bssr\b/i;
36
+ const SERVER_ONLY = /node\.?js|server[- ]side|\bhttp adapter\b|\bhttp\/2\b|\bssr\b|\bssrf\b|no_proxy|proxy-authorization|cloud metadata/i;
37
37
  const SEVERITY = { critical: 'critical', high: 'high', moderate: 'medium', low: 'low', info: 'low' };
38
38
  const RANK = ['low', 'medium', 'high', 'critical'];
39
39
 
40
40
  // Advisories for the exact versions of runtime dependencies (devDependencies never ship).
41
- export async function vulnerableDependencies(registry, versions) {
41
+ export async function vulnerableDependencies(registry, versions, latestOf = {}) {
42
42
  const names = Object.keys(versions);
43
43
  if (!names.length) return [];
44
44
  const advisories = await registry.advisories(versions);
@@ -55,7 +55,18 @@ export async function vulnerableDependencies(registry, versions) {
55
55
  const sevOf = (a) => (SERVER_ONLY.test(a.title || '') ? 'low' : SEVERITY[a.severity] || 'low');
56
56
  const severity = relevant.map(sevOf).sort((a, b) => RANK.indexOf(b) - RANK.indexOf(a))[0];
57
57
  const serverOnly = relevant.filter((a) => SERVER_ONLY.test(a.title || '')).length;
58
- const fixedIn = minimumFixed(relevant);
58
+ let fixedIn = minimumFixed(relevant);
59
+ // Compare with what is published: "<=2.0.2" when 2.0.2 is the latest release means no fix exists.
60
+ const newest = latestOf[name];
61
+ let noFix = false;
62
+ if (fixedIn && newest) {
63
+ const after = fixedIn.startsWith('a release after ') ? fixedIn.slice('a release after '.length) : null;
64
+ if (after) {
65
+ if (compareVersions(newest, after) > 0) fixedIn = newest;
66
+ else noFix = true;
67
+ } else if (compareVersions(newest, fixedIn) < 0) noFix = true;
68
+ if (noFix) fixedIn = null;
69
+ }
59
70
  const top = relevant
60
71
  .slice()
61
72
  .sort((a, b) => RANK.indexOf(sevOf(b)) - RANK.indexOf(sevOf(a)))
@@ -65,9 +76,9 @@ export async function vulnerableDependencies(registry, versions) {
65
76
  id: `vuln:${name}`,
66
77
  severity,
67
78
  area: 'security',
68
- title: `${name} ${version}: ${relevant.length} known vulnerabilit${relevant.length === 1 ? 'y' : 'ies'}`,
69
- detail: `${top.join('; ')}${relevant.length > 3 ? `; and ${relevant.length - 3} more` : ''}. ${fixedIn ? `Fixed in ${fixedIn} or later.` : 'Check the advisories for a fixed version.'}${serverOnly ? ` ${serverOnly} of them concern Node.js/server use only and are counted as low.` : ''} Source: GitHub Advisory Database (via npm).`,
70
- fix: { kind: 'vuln-dep', name, from: version, to: fixedIn, severity },
79
+ title: `${name} ${version}: ${relevant.length} known vulnerabilit${relevant.length === 1 ? 'y' : 'ies'}${fixedIn ? ` (fixed in ${fixedIn})` : noFix ? ' (no fixed release)' : ''}`,
80
+ detail: `${top.join('; ')}${relevant.length > 3 ? `; and ${relevant.length - 3} more` : ''}. ${fixedIn ? `Fixed in ${fixedIn}${fixedIn.startsWith('a release') ? '' : ' or later'}.` : noFix ? `No fixed release exists yet (the latest is ${newest}): replace it, or make sure it never handles untrusted input.` : 'Check the advisories for a fixed version.'}${serverOnly ? ` ${serverOnly} of them concern Node.js/server use only and are counted as low.` : ''} Source: GitHub Advisory Database (via npm).`,
81
+ fix: { kind: 'vuln-dep', name, from: version, to: fixedIn, noFix, latest: newest || null, severity },
71
82
  });
72
83
  }
73
84
  return findings;
@@ -87,15 +98,17 @@ function applies(version, range) {
87
98
  });
88
99
  }
89
100
 
101
+ // The lowest version outside every advisory's range. "<1.2.3" is fixed in 1.2.3; "<=1.2.2" only
102
+ // says "after 1.2.2", which counts when it is the highest bound.
90
103
  function minimumFixed(advisories) {
91
104
  let fixed = null;
92
105
  for (const a of advisories) {
93
- const bounds = [...String(a.vulnerable_versions || '').matchAll(/<\s*(\d+\.\d+\.\d+)/g)].map((m) => m[1]);
106
+ const bounds = [...String(a.vulnerable_versions || '').matchAll(/<(=?)\s*(\d+\.\d+\.\d+)/g)].map((m) => ({ v: m[2], after: m[1] === '=' }));
94
107
  if (!bounds.length) return null; // an advisory without a fixed version
95
- const hi = bounds.sort(compareVersions).at(-1);
96
- if (!fixed || compareVersions(hi, fixed) > 0) fixed = hi;
108
+ const hi = bounds.sort((x, y) => compareVersions(x.v, y.v) || (x.after ? 1 : -1)).at(-1);
109
+ if (!fixed || compareVersions(hi.v, fixed.v) > 0 || (compareVersions(hi.v, fixed.v) === 0 && hi.after)) fixed = hi;
97
110
  }
98
- return fixed;
111
+ return fixed && (fixed.after ? `a release after ${fixed.v}` : fixed.v);
99
112
  }
100
113
 
101
114
  // A password (or the variables built from it) passed to a log, crash report, analytics event,