nativekeel 0.1.17 → 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.17:** every vulnerable package shows the version that fixes it, and Expo apps get one line for the packages the SDK upgrade moves instead of a long list. **0.1.16:** deep-link checks: unverified Android App Links, and the template `myapp://` scheme that other apps can claim (MASVS-PLATFORM). 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.17",
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
@@ -55,6 +55,11 @@ function isNativeModule(root, name, dir) {
55
55
  return false;
56
56
  }
57
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;
58
63
  // Without node_modules, ask React Native Directory. Its ios/android flags mean "works on",
59
64
  // not "has native code" (pure JS packages set them too), so prefer its hasNativeCode.
60
65
  if (dir && dir.github && typeof dir.github.hasNativeCode === 'boolean') return dir.github.hasNativeCode;
@@ -426,7 +431,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
426
431
  const v = installedVersion(project.root, name) || locked[name];
427
432
  if (v && /^\d+\.\d+\.\d+/.test(v)) exact[name] = v;
428
433
  }
429
- const vulns = await vulnerableDependencies(registry, exact);
434
+ const vulns = await vulnerableDependencies(registry, exact, latest);
430
435
  if (vulns === null) net.failed++;
431
436
  for (const f of vulns || []) add(f);
432
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
  }
package/src/plan.js CHANGED
@@ -3,6 +3,7 @@ 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';
6
7
  import { expoPinnedNames, movesWithExpoSdk } from './expo-pins.js';
7
8
 
8
9
  // Turns a scan result into an ordered upgrade plan. The order matters more than the steps:
@@ -146,6 +147,29 @@ function safetyNetPhase(tool) {
146
147
  };
147
148
  }
148
149
 
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)}`);
168
+ }
169
+ }
170
+ return steps;
171
+ }
172
+
149
173
  export function buildPlan(result) {
150
174
  const isExpo = !!result.project.expo;
151
175
  const basePm = commands[packageManager(result.project.root)];
@@ -260,10 +284,13 @@ export function buildPlan(result) {
260
284
  } else if (f.fix.kind === 'ats') {
261
285
  sec.push({ sev, step: `In \`${f.fix.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.` });
262
286
  } else if (f.fix.kind === 'security') {
263
- 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 });
264
290
  } else if (f.fix.kind === 'vuln-dep' && (f.fix.severity === 'critical' || f.fix.severity === 'high')) {
265
291
  const v = f.fix;
266
- 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.'}` });
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.'}` });
267
294
  }
268
295
  }
269
296
  const securitySteps = sec.sort((a, b) => a.sev - b.sev).map((x) => x.step);
@@ -338,12 +365,26 @@ export function buildPlan(result) {
338
365
  }
339
366
 
340
367
  const expoMoves = byKind('bump-dep').filter((d) => movesWithExpo(d.name));
341
- 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);
342
383
  if (nativeBumps.length) {
343
384
  phases.push({
344
385
  title: 'Update native modules on the current React Native version',
345
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.',
346
- 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)` : '')),
347
388
  });
348
389
  }
349
390
 
@@ -515,7 +556,7 @@ export function buildPlan(result) {
515
556
  phases.push({
516
557
  title: 'Update JavaScript-only packages',
517
558
  why: 'These do not block the upgrade. Do them last, one major at a time, following each migration guide.',
518
- steps: jsBumps.map((d) => `\`${d.name}\` ${d.from} → ${d.to}`),
559
+ steps: bumpSteps(jsBumps),
519
560
  });
520
561
  }
521
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.
package/src/security.js CHANGED
@@ -38,7 +38,7 @@ const SEVERITY = { critical: 'critical', high: 'high', moderate: 'medium', 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'}${fixedIn ? ` (fixed in ${fixedIn})` : ''}`,
69
- detail: `${top.join('; ')}${relevant.length > 3 ? `; and ${relevant.length - 3} more` : ''}. ${fixedIn ? `Fixed in ${fixedIn}${fixedIn.startsWith('a release') ? '' : ' 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;