nativekeel 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,8 @@ 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.8:** known vulnerabilities in the exact dependency versions you ship, passwords that leak into crash reports, databases or AsyncStorage, unsafe WebViews, and setups that build fine but crash at runtime. Tuned on 280+ open-source React Native and Expo apps to keep false alarms out. [All releases →](https://nativekeel.com/changelog)
10
+
9
11
  It answers the questions that decide whether your app can still ship:
10
12
 
11
13
  - **Is your React Native / Expo version still supported?** React Native supports the latest minor and the two before it.
@@ -15,6 +17,9 @@ It answers the questions that decide whether your app can still ship:
15
17
  - **Will the stores accept your next build?** Google Play `targetSdk` and 16 KB page size (it opens your built APK/AAB and checks the alignment of every native library), and the iOS privacy manifest.
16
18
  - **Are secrets shipped inside your app?** AWS, Stripe, OpenAI, Anthropic, GitHub, Slack, SendGrid, Twilio, Shopify, Google OAuth keys, private keys and Supabase `service_role` keys (anon keys are fine), plus `.env` values that `react-native-config` or `EXPO_PUBLIC_` compile into the bundle. Server-side folders are reported separately.
17
19
  - **Is transport security off?** Cleartext HTTP or `debuggable` in the release Android manifest, `NSAllowsArbitraryLoads` on iOS.
20
+ - **Do you ship known vulnerabilities?** Advisories from the GitHub Advisory Database for the exact versions in your lockfile or `node_modules`. Advisories that only affect Node.js servers are kept, at low severity.
21
+ - **Can someone read your users' data?** Passwords that flow into crash reports, analytics, remote databases or plain AsyncStorage (followed through intermediate variables), WebViews that let page scripts read local files or load HTTP into HTTPS, Android backups that copy tokens.
22
+ - **Will it crash at runtime?** Reanimated without its Babel plugin or `react-native-worklets`, mixed `@react-native-firebase/*` or `@react-navigation/*` majors, a second copy of React or React Native inside a dependency, Kotlin modules that cannot load through the New Architecture interop layer, AppDelegate setups that crash in Release.
18
23
  - **Leftovers from old templates?** Flipper, JavaScriptCore instead of Hermes.
19
24
 
20
25
  ```
@@ -84,7 +89,7 @@ Agencies can put their own name on the HTML report: `npx nativekeel plan --html
84
89
 
85
90
  ## Why you can run this on private code
86
91
 
87
- - **Your code never leaves your machine.** The only requests are package-name lookups on npm and React Native Directory. `--offline` makes none; a test traps `fetch` to prove it.
92
+ - **Your code never leaves your machine.** The only requests are package-name lookups on npm and React Native Directory, and one request to npm's advisory endpoint with the names and exact versions of your runtime dependencies (what `npm audit` sends). `--offline` makes none; a test traps `fetch` to prove it.
88
93
  - **Zero dependencies, no install scripts.** Only Node.js built-ins.
89
94
  - **Read-only.** It never changes your project.
90
95
  - **Secrets are always masked** in every output.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.6",
4
- "description": "Health check for React Native apps: version support, New Architecture blockers, unmaintained native modules and leaked secrets.",
3
+ "version": "0.1.8",
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": {
7
7
  "nativekeel": "bin/nativekeel.js"
@@ -20,7 +20,13 @@
20
20
  "new-architecture",
21
21
  "doctor",
22
22
  "health-check",
23
- "security"
23
+ "security",
24
+ "vulnerabilities",
25
+ "audit",
26
+ "crash",
27
+ "upgrade-plan",
28
+ "google-play",
29
+ "app-store"
24
30
  ],
25
31
  "license": "MIT",
26
32
  "scripts": {
package/src/analyze.js CHANGED
@@ -4,6 +4,9 @@ import { cleanVersion, findPackageDir, installedVersion } from './project.js';
4
4
  import { createRegistry, fetchJson } from './registry.js';
5
5
  import { scanSecrets } from './secrets.js';
6
6
  import { nativeChecks } from './native.js';
7
+ import { stabilityChecks } from './stability.js';
8
+ import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup } from './security.js';
9
+ import { lockedVersions } from './lockfile.js';
7
10
  import { findUnused, filesImportingWith } from './usage.js';
8
11
  import { matchKnownIssues, compareVersions } from './known-issues.js';
9
12
  import { COMPAT, bestRange, checkCompat } from './compat.js';
@@ -15,6 +18,7 @@ import {
15
18
  RECENT_RELEASE_DAYS,
16
19
  STALE_RELEASE_DAYS,
17
20
  PLAY_TARGET_SDK,
21
+ RENAMED,
18
22
  REPLACEMENTS,
19
23
  SUPPORTED_EXPO_MAJORS,
20
24
  SUPPORTED_RN_MINORS,
@@ -78,7 +82,7 @@ export function satisfiesExpoRange(version, range) {
78
82
  export async function analyze(project, { get, now = new Date(), offline: forcedOffline = false } = {}) {
79
83
  // Requests that failed even after retries (rate limits, network): reported, never hidden.
80
84
  const net = { failed: 0 };
81
- const registry = createRegistry(get || ((url, headers) => fetchJson(url, headers, net)));
85
+ const registry = createRegistry(get || ((url, headers, body) => fetchJson(url, headers, net, { body })));
82
86
  const findings = [];
83
87
  const add = (f) => findings.push(f);
84
88
 
@@ -263,7 +267,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
263
267
  }
264
268
  // Only code that has to follow React Native or the OS goes stale; a finished JS utility does not.
265
269
  const stale = !dep.unmaintained && !revived && age !== null && age > STALE_RELEASE_DAYS && (native || !!release.rnLink);
266
- const alternatives = dir && dir.alternatives && dir.alternatives.length ? dir.alternatives : REPLACEMENTS[name] || [];
270
+ const alternatives = RENAMED[name] ? [RENAMED[name]] : dir && dir.alternatives && dir.alternatives.length ? dir.alternatives : REPLACEMENTS[name] || [];
267
271
  if (noNewArch || dep.unmaintained || stale) {
268
272
  const problems = [
269
273
  noNewArch && 'no New Architecture support',
@@ -279,7 +283,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
279
283
  area: 'dependency',
280
284
  title: `${name}: ${problems.join(', ')}`,
281
285
  detail: `${why}${alternatives.length ? ` Alternatives: ${alternatives.join(', ')}.` : ''}`,
282
- fix: { kind: 'replace-dep', name, native, noNewArch, unmaintained: dep.unmaintained || stale, alternatives },
286
+ fix: { kind: 'replace-dep', name, native, noNewArch, unmaintained: dep.unmaintained || stale, alternatives, renamedTo: RENAMED[name] || null },
283
287
  });
284
288
  } else if (revived) {
285
289
  add({
@@ -397,6 +401,24 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
397
401
  findings.push(...nativeChecks(project, { rnLatest: rn && rn.latest, now }));
398
402
 
399
403
  // 7. Secrets
404
+ // Setups that build and then crash at runtime.
405
+ for (const f of stabilityChecks(project)) add(f);
406
+
407
+ // Security beyond leaked keys.
408
+ for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root)]) add(f);
409
+ if (!forcedOffline) {
410
+ // Exact versions only (installed, else the lockfile): a range would be a guess.
411
+ const locked = lockedVersions(project.root, project.deps);
412
+ const exact = {};
413
+ for (const name of Object.keys(project.deps)) {
414
+ const v = installedVersion(project.root, name) || locked[name];
415
+ if (v && /^\d+\.\d+\.\d+/.test(v)) exact[name] = v;
416
+ }
417
+ const vulns = await vulnerableDependencies(registry, exact);
418
+ if (vulns === null) net.failed++;
419
+ for (const f of vulns || []) add(f);
420
+ }
421
+
400
422
  for (const s of scanSecrets(project.root)) {
401
423
  const value = s.preview ? `Value ${s.preview}. ` : '';
402
424
  const where = `${s.file}:${s.line}`;
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Exact versions of direct dependencies, read from the lockfile next to the app or at the
5
+ // workspace root (monorepos). Used where a range from package.json would be a guess, e.g.
6
+ // matching security advisories. Returns { name: version } for the names it could resolve.
7
+
8
+ const readText = (file) => {
9
+ try {
10
+ return fs.readFileSync(file, 'utf8');
11
+ } catch {
12
+ return null;
13
+ }
14
+ };
15
+
16
+ function findUp(root, names) {
17
+ let dir = root;
18
+ while (true) {
19
+ for (const n of names) {
20
+ const file = path.join(dir, n);
21
+ if (fs.existsSync(file)) return file;
22
+ }
23
+ const parent = path.dirname(dir);
24
+ if (parent === dir) return null;
25
+ dir = parent;
26
+ }
27
+ }
28
+
29
+ export function lockedVersions(root, declared) {
30
+ const file = findUp(root, ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock']);
31
+ if (!file) return {};
32
+ const text = readText(file);
33
+ if (!text) return {};
34
+ const rel = path.relative(path.dirname(file), root).split(path.sep).join('/');
35
+ if (file.endsWith('.json')) return fromNpm(text, rel, declared);
36
+ if (file.endsWith('pnpm-lock.yaml')) return fromPnpm(text, rel || '.', declared);
37
+ return fromYarn(text, declared);
38
+ }
39
+
40
+ function fromNpm(text, rel, declared) {
41
+ let lock;
42
+ try {
43
+ lock = JSON.parse(text);
44
+ } catch {
45
+ return {};
46
+ }
47
+ const out = {};
48
+ for (const name of Object.keys(declared)) {
49
+ const pkgs = lock.packages || {};
50
+ const hit =
51
+ (rel && pkgs[`${rel}/node_modules/${name}`]) || pkgs[`node_modules/${name}`] || (lock.dependencies && lock.dependencies[name]);
52
+ if (hit && hit.version && /^\d/.test(hit.version)) out[name] = hit.version;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // pnpm-lock.yaml (v6 and v9): importers.<path>.(dependencies|devDependencies).<name>.version
58
+ function fromPnpm(text, importer, declared) {
59
+ const out = {};
60
+ const lines = text.split('\n');
61
+ let inImporters = false;
62
+ let current = null;
63
+ let section = null;
64
+ let pkg = null;
65
+ for (const line of lines) {
66
+ if (/^importers:\s*$/.test(line)) {
67
+ inImporters = true;
68
+ continue;
69
+ }
70
+ if (inImporters && /^\S/.test(line)) {
71
+ // Next top-level key. pnpm 11 can write several YAML documents, so keep looking.
72
+ inImporters = false;
73
+ current = null;
74
+ continue;
75
+ }
76
+ if (!inImporters) continue;
77
+ const imp = line.match(/^ {2}(\S.*?):\s*$/);
78
+ if (imp) {
79
+ current = imp[1].replace(/^['"]|['"]$/g, '');
80
+ section = null;
81
+ continue;
82
+ }
83
+ if (current !== importer) continue;
84
+ const sec = line.match(/^ {4}(dependencies|devDependencies|optionalDependencies):\s*$/);
85
+ if (sec) {
86
+ section = sec[1];
87
+ continue;
88
+ }
89
+ if (!section) continue;
90
+ const name = line.match(/^ {6}(\S.*?):\s*$/);
91
+ if (name) {
92
+ pkg = name[1].replace(/^['"]|['"]$/g, '');
93
+ continue;
94
+ }
95
+ const ver = line.match(/^ {8}version:\s*['"]?([0-9][^\s('"]*)/);
96
+ if (ver && pkg && declared[pkg]) out[pkg] = ver[1];
97
+ }
98
+ return out;
99
+ }
100
+
101
+ // yarn.lock v1 ("name@range", name@range:\n version "x") and berry ("name@npm:range":\n version: x)
102
+ function fromYarn(text, declared) {
103
+ const byPattern = {};
104
+ let patterns = null;
105
+ for (const line of text.split('\n')) {
106
+ if (/^\S.*:\s*$/.test(line) && !line.startsWith('#') && !line.startsWith('__metadata')) {
107
+ patterns = line
108
+ .replace(/:\s*$/, '')
109
+ .split(/,\s*/)
110
+ .map((p) => p.trim().replace(/^"|"$/g, ''));
111
+ continue;
112
+ }
113
+ const ver = line.match(/^\s+version:?\s+"?([0-9][^"\s]*)"?/);
114
+ if (ver && patterns) {
115
+ for (const p of patterns) byPattern[p] = ver[1];
116
+ patterns = null;
117
+ }
118
+ }
119
+ const out = {};
120
+ for (const [name, range] of Object.entries(declared)) {
121
+ const v = byPattern[`${name}@${range}`] || byPattern[`${name}@npm:${range}`];
122
+ if (v) out[name] = v;
123
+ }
124
+ return out;
125
+ }
package/src/plan.js CHANGED
@@ -65,6 +65,9 @@ const WEIGHTS = {
65
65
  'patch-version': () => 1,
66
66
  'folly-flags': () => 0.25,
67
67
  'default-react-host': () => 0.25,
68
+ crash: () => 1,
69
+ security: () => 1,
70
+ 'vuln-dep': (f) => (f.severity === 'critical' || f.severity === 'high' ? 1 : 0.25),
68
71
  };
69
72
 
70
73
  // Routine version bumps are many but cheap next to a React Native, architecture or store
@@ -217,6 +220,15 @@ export function buildPlan(result) {
217
220
  }
218
221
 
219
222
  // Low-severity notes stay in the report; the plan only carries what can break the app.
223
+ const crashes = byKind('crash');
224
+ if (crashes.length) {
225
+ phases.push({
226
+ title: 'Fix crash risks',
227
+ why: 'These setups build fine and crash at runtime. They are cheap to fix and every later step is easier to test without them.',
228
+ steps: crashes.map((c) => c.step),
229
+ });
230
+ }
231
+
220
232
  const known = byKind('known-issue').filter((k) => k.severity !== 'low');
221
233
  if (known.length) {
222
234
  phases.push({
@@ -271,8 +283,15 @@ export function buildPlan(result) {
271
283
  for (const a of byKind('ats')) {
272
284
  securitySteps.push(`In \`${a.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.`);
273
285
  }
286
+ for (const f of byKind('security')) securitySteps.push(f.step);
287
+ const vulns = byKind('vuln-dep').filter((v) => v.severity === 'critical' || v.severity === 'high');
288
+ for (const v of vulns) {
289
+ securitySteps.push(`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.'}`);
290
+ }
291
+ const minorVulns = byKind('vuln-dep').length - vulns.length;
292
+ if (minorVulns) securitySteps.push(`${minorVulns} more package${minorVulns === 1 ? ' has' : 's have'} moderate or low advisories: update them with the routine package updates below.`);
274
293
  if (securitySteps.length) {
275
- phases.push({ title: 'Tighten transport security', why: 'Unencrypted traffic exposes user data on public networks.', steps: securitySteps });
294
+ phases.push({ title: 'Close security gaps', why: 'Attackers read app bundles, network traffic and logs. These are the ways in this app hands them something.', steps: securitySteps });
276
295
  }
277
296
 
278
297
  const cleanup = [];
@@ -314,6 +333,9 @@ export function buildPlan(result) {
314
333
  why: 'These packages decide how far you can upgrade. Swap them while the app still runs on its current version, so every problem has one cause.',
315
334
  steps: replace.map((d) => {
316
335
  const reason = [d.noNewArch && 'no New Architecture support', d.unmaintained && 'unmaintained'].filter(Boolean).join(', ');
336
+ if (d.renamedTo) {
337
+ return `\`${d.name}\` moved to \`${d.renamedTo}\` (same API). \`${pm.remove} ${d.name} && ${pm.add} ${d.renamedTo}\`, then update the imports.`;
338
+ }
317
339
  if (interopPatched.has(d.name) && !d.unmaintained) {
318
340
  return `\`${d.name}\` (${reason}, native). The New Architecture step below patches it so it loads; move to a release with a TurboModule spec once one exists.`;
319
341
  }
@@ -397,20 +419,33 @@ export function buildPlan(result) {
397
419
  let from = rn.from;
398
420
  const tracked = result.deps.filter((d) => COMPAT[d.name]);
399
421
  const lastRange = {};
400
- const steps = upgradeHops(rn.from, rn.to).map((to) => {
422
+ const lastMajor = {};
423
+ const hopList = upgradeHops(rn.from, rn.to);
424
+ // When the New Architecture switch happens on the way, library versions up to that stop
425
+ // must still run on the legacy architecture (Reanimated 4, for one, does not).
426
+ const switchAt = deferredArch
427
+ ? hopList.findIndex((h) => Number(h.split('.')[1]) >= 76 && Number(h.split('.')[1]) < NEW_ARCH_ONLY_MINOR)
428
+ : -1;
429
+ const majorOf = (v) => Number(String(v).split('.')[0]);
430
+ const steps = hopList.map((to, i) => {
401
431
  const minor = Number(to.split('.')[1]);
432
+ const archOn = switchAt < 0 || i > switchAt;
402
433
  const bumps = tracked
403
434
  .map((d) => {
404
- const best = bestRange(d.name, minor, true);
405
- // Prefer staying on the installed major when it still supports this hop.
406
- const major = d.version ? Number(d.version.split('.')[0]) : null;
407
- const same = major !== null && best && Number(best.from.split('.')[0]) !== major ? bestRange(d.name, minor, true, { major }) : null;
435
+ const best = bestRange(d.name, minor, archOn);
436
+ // Stay on the major of the previous step (or the installed one) while it still works.
437
+ const major = lastMajor[d.name] ?? (d.version ? majorOf(d.version) : null);
438
+ const same = major !== null && best && majorOf(best.from) !== major ? bestRange(d.name, minor, archOn, { major }) : null;
408
439
  return { name: d.name, best, same };
409
440
  })
410
441
  .filter((b) => b.best && (b.same || b.best).range !== lastRange[b.name])
411
442
  .map((b) => {
412
- lastRange[b.name] = (b.same || b.best).range;
413
- return b.same ? `\`${b.name}\` ${b.same.range} (or ${b.best.range}, a major migration)` : `\`${b.name}\` ${b.best.range}`;
443
+ const chosen = b.same || b.best;
444
+ const forcedMajor = !b.same && lastMajor[b.name] !== undefined && majorOf(chosen.from) !== lastMajor[b.name];
445
+ lastRange[b.name] = chosen.range;
446
+ lastMajor[b.name] = majorOf(chosen.from);
447
+ if (b.same) return `\`${b.name}\` ${b.same.range} (or ${b.best.range}, a major migration)`;
448
+ return `\`${b.name}\` ${b.best.range}${forcedMajor ? ' (a major migration: read its migration guide first)' : ''}`;
414
449
  });
415
450
  const step = `${from} → ${to}: apply the diff from ${UPGRADE_HELPER_URL(from, to)}${bumps.length ? `, move ${bumps.join(' and ')}` : ''}, reinstall pods, build both platforms, commit.`;
416
451
  from = to;
package/src/registry.js CHANGED
@@ -5,10 +5,12 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
5
5
  // GET a JSON document. Returns null for "not found" (a package that does not exist is an
6
6
  // answer, not an error). Rate limits (429) and server errors are retried with backoff; if the
7
7
  // request still fails, `stats.failed` is incremented so the report can say it is incomplete.
8
- export async function fetchJson(url, headers = {}, stats = null, { retries = 3, wait = sleep } = {}) {
8
+ export async function fetchJson(url, headers = {}, stats = null, { retries = 3, wait = sleep, body = null } = {}) {
9
9
  for (let attempt = 0; ; attempt++) {
10
10
  try {
11
- const res = await fetch(url, { headers: { ...UA, ...headers }, signal: AbortSignal.timeout(30000) });
11
+ const init = { headers: { ...UA, ...headers }, signal: AbortSignal.timeout(30000) };
12
+ if (body) Object.assign(init, { method: 'POST', body });
13
+ const res = await fetch(url, init);
12
14
  if (res.ok) return await res.json();
13
15
  if (res.status === 404) return null;
14
16
  if ((res.status === 429 || res.status >= 500) && attempt < retries) {
@@ -106,6 +108,20 @@ export function createRegistry(get = fetchJson) {
106
108
  return out;
107
109
  },
108
110
 
111
+ // Security advisories for exact versions (the endpoint `npm audit` uses). Sends package
112
+ // names and versions, nothing else. Returns { name: [advisory] }, or null if it failed.
113
+ async advisories(versions) {
114
+ const out = {};
115
+ const names = Object.keys(versions);
116
+ for (let i = 0; i < names.length; i += 200) {
117
+ const chunk = Object.fromEntries(names.slice(i, i + 200).map((n) => [n, [versions[n]]]));
118
+ const data = await get('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', { 'Content-Type': 'application/json' }, JSON.stringify(chunk));
119
+ if (!data) return null;
120
+ Object.assign(out, data);
121
+ }
122
+ return out;
123
+ },
124
+
109
125
  // Stable versions of a package, newest release line first.
110
126
  async releaseLines(name, pick) {
111
127
  // The abbreviated "install" document is a fraction of the full packument's size.
@@ -15,11 +15,19 @@ export function htmlReport(result, { brand = null, plan = null } = {}) {
15
15
  const defaultOn = !!result.project.reactNative && Number(result.project.reactNative.split('.')[1]) >= 76;
16
16
  const arch = (v) => (v === true ? 'on' : v === false ? 'off' : defaultOn ? 'on (default)' : 'off (default)');
17
17
 
18
+ // Outdated packages are many and alike: one collapsible table instead of a card each.
19
+ const isBump = (f) => f.id.startsWith('dep-major:');
18
20
  const rows = SEVERITIES.flatMap((sev) =>
19
21
  result.findings
20
- .filter((f) => f.severity === sev)
22
+ .filter((f) => f.severity === sev && !isBump(f))
21
23
  .map((f) => `<li class="f ${sev}"><span class="tag">${sevLabel[sev]}</span><div><strong>${rich(f.title)}</strong><p>${rich(f.detail)}</p></div></li>`),
22
24
  ).join('\n');
25
+ const bumps = result.findings.filter(isBump);
26
+ const bumpTable = bumps.length
27
+ ? `<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
28
+ .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
+ .join('')}</tbody></table></details>`
30
+ : '';
23
31
 
24
32
  const planHtml = plan
25
33
  ? `<section><h2>Upgrade plan <small>scope: ${esc(plan.complexity.size)}</small></h2>${plan.phases
@@ -50,6 +58,7 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:32px 0 12px}h2 small{f
50
58
  .stat.critical b{color:var(--critical)}.stat.high b{color:var(--high)}
51
59
  .facts{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 16px;margin-top:12px;display:flex;flex-wrap:wrap;gap:6px 24px;font-size:14px}
52
60
  ul.list{list-style:none;padding:0;margin:0;display:grid;gap:8px}
61
+ details.bumps{margin-top:8px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--low);border-radius:8px;padding:10px 14px}details.bumps summary{cursor:pointer;font-weight:600}details.bumps table{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px}details.bumps th,details.bumps td{text-align:left;padding:4px 8px;border-top:1px solid var(--line)}.muted{color:var(--muted);font-weight:400}
53
62
  .f{display:flex;gap:12px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--low);border-radius:8px;padding:10px 14px}
54
63
  .f p{margin:2px 0 0;color:var(--muted);font-size:14px}.f strong{overflow-wrap:anywhere}
55
64
  .tag{flex:none;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;min-width:64px;padding-top:2px}
@@ -80,7 +89,7 @@ ${p.android && p.android.targetSdk ? `<span>targetSdk <b>${p.android.targetSdk}<
80
89
  <span>${result.deps.length} dependencies (${result.deps.filter((d) => d.native).length} native)</span>
81
90
  </div>
82
91
  <h2>Findings</h2>
83
- ${result.findings.length ? `<ul class="list">${rows}</ul>` : '<p>No issues found.</p>'}
92
+ ${result.findings.length ? `${rows ? `<ul class="list">${rows}</ul>` : ''}${bumpTable}` : '<p>No issues found.</p>'}
84
93
  ${planHtml}
85
94
  <footer>${footer}</footer>
86
95
  </main></body></html>
@@ -49,7 +49,7 @@ export function sarifReport(result, { version = '0.0.0' } = {}) {
49
49
  ruleId: f.id.split(':')[0],
50
50
  level: LEVEL[f.severity],
51
51
  message: { text: `${f.title}. ${f.detail}` },
52
- locations: [{ physicalLocation: { artifactLocation: { uri: loc.uri }, region: { startLine: loc.line } } }],
52
+ locations: [{ physicalLocation: { artifactLocation: { uri: toUri(loc.uri) }, region: { startLine: loc.line } } }],
53
53
  // Stable across runs so code scanning tracks one alert per finding.
54
54
  partialFingerprints: { nativekeelId: f.id },
55
55
  properties: { severity: f.severity, area: f.area },
@@ -59,3 +59,8 @@ export function sarifReport(result, { version = '0.0.0' } = {}) {
59
59
  ],
60
60
  };
61
61
  }
62
+
63
+ // SARIF artifact locations are URIs: encode each path segment (spaces, #, %), keep the slashes.
64
+ function toUri(file) {
65
+ return file.split(/[\\/]/).map(encodeURIComponent).join('/');
66
+ }
package/src/rules.js CHANGED
@@ -70,6 +70,21 @@ export const REPLACEMENTS = {
70
70
  'react-native-print': ['expo-print'],
71
71
  'react-native-i18n': ['react-native-localize', 'expo-localization'],
72
72
  'expo-av': ['expo-audio', 'expo-video'],
73
+ 'react-native-snap-carousel': ['react-native-reanimated-carousel'],
74
+ 'react-native-iphone-x-helper': ['react-native-safe-area-context'],
75
+ 'react-native-slider': ['@react-native-community/slider'],
76
+ 'react-native-datepicker': ['@react-native-community/datetimepicker'],
77
+ };
78
+
79
+ // Packages that moved to a new name with the same API: swap the package, update the imports.
80
+ export const RENAMED = {
81
+ '@react-native-community/async-storage': '@react-native-async-storage/async-storage',
82
+ '@react-native-community/cameraroll': '@react-native-camera-roll/camera-roll',
83
+ '@react-native-community/masked-view': '@react-native-masked-view/masked-view',
84
+ '@react-native-community/viewpager': 'react-native-pager-view',
85
+ '@react-native-community/picker': '@react-native-picker/picker',
86
+ '@react-native-community/clipboard': '@react-native-clipboard/clipboard',
87
+ 'react-native-netinfo': '@react-native-community/netinfo',
73
88
  };
74
89
 
75
90
  export const UPGRADE_HELPER_URL = (from, to) =>
@@ -0,0 +1,259 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { compareVersions } from './known-issues.js';
4
+
5
+ // Security checks beyond leaked keys: known vulnerabilities in shipped dependencies, passwords
6
+ // that flow into logs or remote writes, unsafe WebView settings, and Android backups.
7
+
8
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'Pods', 'build', '.gradle', 'DerivedData', '.expo', 'dist', 'coverage', 'vendor', '__tests__', '__mocks__', 'e2e']);
9
+ const JS_EXT = /\.(?:[cm]?[jt]sx?)$/;
10
+ const MAX_FILE_BYTES = 512 * 1024;
11
+
12
+ function* jsFiles(dir) {
13
+ let entries;
14
+ try {
15
+ entries = fs.readdirSync(dir, { withFileTypes: true });
16
+ } catch {
17
+ return;
18
+ }
19
+ for (const e of entries) {
20
+ if (e.isDirectory()) {
21
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) yield* jsFiles(path.join(dir, e.name));
22
+ } else if (JS_EXT.test(e.name) && !/\.(test|spec)\./.test(e.name)) {
23
+ yield path.join(dir, e.name);
24
+ }
25
+ }
26
+ }
27
+
28
+ const read = (file) => {
29
+ try {
30
+ return fs.statSync(file).size > MAX_FILE_BYTES ? '' : fs.readFileSync(file, 'utf8');
31
+ } catch {
32
+ return '';
33
+ }
34
+ };
35
+
36
+ const SERVER_ONLY = /node\.?js|server[- ]side|\bhttp adapter\b|\bssr\b/i;
37
+ const SEVERITY = { critical: 'critical', high: 'high', moderate: 'medium', low: 'low', info: 'low' };
38
+ const RANK = ['low', 'medium', 'high', 'critical'];
39
+
40
+ // Advisories for the exact versions of runtime dependencies (devDependencies never ship).
41
+ export async function vulnerableDependencies(registry, versions) {
42
+ const names = Object.keys(versions);
43
+ if (!names.length) return [];
44
+ const advisories = await registry.advisories(versions);
45
+ if (!advisories) return null; // request failed: the caller says the check did not run
46
+ const findings = [];
47
+ for (const [name, list] of Object.entries(advisories)) {
48
+ const version = versions[name];
49
+ // The endpoint answers for the versions we sent; double-check the range where it is simple.
50
+ const relevant = list.filter((a) => applies(version, a.vulnerable_versions));
51
+ if (!relevant.length) continue;
52
+ // Advisories about Node.js-only code paths (axios' HTTP adapter, server rendering) do not
53
+ // reach a React Native app: keep them visible, but at low severity.
54
+ const sevOf = (a) => (SERVER_ONLY.test(a.title || '') ? 'low' : SEVERITY[a.severity] || 'low');
55
+ const severity = relevant.map(sevOf).sort((a, b) => RANK.indexOf(b) - RANK.indexOf(a))[0];
56
+ const serverOnly = relevant.filter((a) => SERVER_ONLY.test(a.title || '')).length;
57
+ const fixedIn = minimumFixed(relevant);
58
+ const top = relevant
59
+ .slice()
60
+ .sort((a, b) => RANK.indexOf(sevOf(b)) - RANK.indexOf(sevOf(a)))
61
+ .slice(0, 3)
62
+ .map((a) => `${a.title}${a.url ? ` (${a.url})` : ''}`);
63
+ findings.push({
64
+ id: `vuln:${name}`,
65
+ severity,
66
+ area: 'security',
67
+ title: `${name} ${version}: ${relevant.length} known vulnerabilit${relevant.length === 1 ? 'y' : 'ies'}`,
68
+ 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).`,
69
+ fix: { kind: 'vuln-dep', name, from: version, to: fixedIn, severity },
70
+ });
71
+ }
72
+ return findings;
73
+ }
74
+
75
+ // "<1.2.3", ">=1.0.0 <1.2.3", "<=1.2.2" and "||" lists. Unknown syntax: trust the endpoint.
76
+ function applies(version, range) {
77
+ if (!range) return true;
78
+ return range.split('||').some((part) => {
79
+ const conds = part.trim().split(/\s+/).filter(Boolean);
80
+ return conds.every((c) => {
81
+ const m = c.match(/^(<=|>=|<|>|=)?(\d+\.\d+\.\d+)/);
82
+ if (!m) return true;
83
+ const cmp = compareVersions(version, m[2]);
84
+ return { '<': cmp < 0, '<=': cmp <= 0, '>': cmp > 0, '>=': cmp >= 0, '=': cmp === 0, undefined: cmp === 0 }[m[1]];
85
+ });
86
+ });
87
+ }
88
+
89
+ function minimumFixed(advisories) {
90
+ let fixed = null;
91
+ for (const a of advisories) {
92
+ const bounds = [...String(a.vulnerable_versions || '').matchAll(/<\s*(\d+\.\d+\.\d+)/g)].map((m) => m[1]);
93
+ if (!bounds.length) return null; // an advisory without a fixed version
94
+ const hi = bounds.sort(compareVersions).at(-1);
95
+ if (!fixed || compareVersions(hi, fixed) > 0) fixed = hi;
96
+ }
97
+ return fixed;
98
+ }
99
+
100
+ // A password (or the variables built from it) passed to a log, crash report, analytics event,
101
+ // remote database write or plain AsyncStorage. Seen in a real app: failed sign-ups were
102
+ // logged to the database as "email-…-password".
103
+ const PASSWORD = /\b(?:password|passwd|pwd|passcode|pin)\b/i;
104
+ const SINKS = [
105
+ { re: /\b(?:crashlytics\(\)\.(?:log|recordError)|Sentry\.capture\w*|Bugsnag\.notify|captureException|captureMessage|sendCrash|recordError|logEvent|analytics\(\)\.\w+|mixpanel\.\w+|amplitude\.\w+|track)\s*\(/, what: 'crash reports or analytics', severity: 'high' },
106
+ { re: /\b(?:database\(\)|firestore\(\)|ref\([^)]*\)|collection\([^)]*\)|doc\([^)]*\))[\s\S]{0,80}?\.(?:set|push|update|add)\s*\(/, what: 'a remote database', severity: 'high' },
107
+ { re: /\bAsyncStorage\.(?:setItem|multiSet|mergeItem)\s*\(/, what: 'unencrypted AsyncStorage', severity: 'medium' },
108
+ { re: /\bconsole\.(?:log|info|warn|error|debug)\s*\(/, what: 'the device log', severity: 'low' },
109
+ ];
110
+
111
+ export function passwordLeaks(root) {
112
+ const findings = [];
113
+ for (const file of jsFiles(root)) {
114
+ const text = read(file);
115
+ if (!text || !PASSWORD.test(text)) continue;
116
+ const lines = text.split('\n');
117
+ // Taint: identifiers that hold a password, or were built from one.
118
+ const tainted = new Set();
119
+ const hits = [];
120
+ lines.forEach((line, i) => {
121
+ if (/secureTextEntry|placeholder|label|title|Text>|i18n|\bt\(|localiz/i.test(line)) return;
122
+ const assign = line.match(/\b(?:const|let|var)\s+(?:\{[^}]*\}|([A-Za-z_$][\w$]*))\s*=(?!=)\s*(.*)/);
123
+ if (assign && assign[1]) {
124
+ // The whole right-hand side: objects and calls often span several lines.
125
+ const rhs = balanced(`${assign[2]}\n${lines.slice(i + 1, i + 12).join('\n')}`);
126
+ // A call's result (a login response, an encrypted value, a hash) is not the password.
127
+ // Concatenations and templates that include the password are still the password.
128
+ const unwrapped = rhs.replace(/^\s*(?:JSON\.stringify|String)\s*\(/, '');
129
+ const isCallResult = wholeCall(unwrapped);
130
+ if (!isCallResult && PASSWORD.test(rhs.replace(/['"`][^'"`\n]*['"`]/g, '')) || [...tainted].some((t) => new RegExp(`\\b${t}\\b`).test(rhs))) tainted.add(assign[1]);
131
+ }
132
+ for (const sink of SINKS) {
133
+ if (!sink.re.test(line)) continue;
134
+ // Exactly the call's arguments, up to the matching parenthesis (they can span lines).
135
+ const args = callArguments(lines.slice(i, i + 12).join('\n'), sink.re);
136
+ const direct = /\bpassword\b|\bpasswd\b|\bpwd\b/i.test(args.replace(/['"`][^'"`]*['"`]/g, ''));
137
+ const viaVar = [...tainted].some((t) => new RegExp(`\\b${t}\\b`).test(args));
138
+ if (direct || viaVar) hits.push({ line: i + 1, sink });
139
+ }
140
+ });
141
+ const worst = hits.sort((a, b) => RANK.indexOf(b.sink.severity) - RANK.indexOf(a.sink.severity))[0];
142
+ if (!worst || worst.sink.severity === 'low') continue; // console.log alone: not reported
143
+ const rel = path.relative(root, file);
144
+ findings.push({
145
+ id: `password-leak:${rel}`,
146
+ severity: worst.sink.severity,
147
+ area: 'security',
148
+ title: `A password may be sent to ${worst.sink.what} (${rel}:${worst.line})`,
149
+ detail: `${hits.length} place${hits.length === 1 ? '' : 's'} in this file pass a password, or a value built from one, to ${[...new Set(hits.map((h) => h.sink.what))].join(', ')}. Anyone with access to those logs or that data can read users' passwords. Log the error code only; never store passwords in AsyncStorage (use the Keychain/Keystore).`,
150
+ fix: { kind: 'security', step: `In \`${rel}\`, stop passing the password (or values built from it) to ${worst.sink.what}; log only error codes. Then delete the records already written.` },
151
+ });
152
+ }
153
+ return findings;
154
+ }
155
+
156
+ // From the start of `text` up to the end of the first statement: stops at a newline or ';' once
157
+ // all brackets opened so far are closed.
158
+ function balanced(text) {
159
+ let depth = 0;
160
+ const start = text.search(/\S/);
161
+ if (start < 0) return '';
162
+ for (let i = start; i < text.length; i++) {
163
+ const c = text[i];
164
+ if ('({['.includes(c)) depth++;
165
+ else if (')}]'.includes(c)) depth--;
166
+ else if ((c === '\n' || c === ';') && depth <= 0) return text.slice(start, i);
167
+ }
168
+ return text.slice(start);
169
+ }
170
+
171
+ // True when `text` is a single call expression (`await f(...)`, `a.b(...)`), with nothing but
172
+ // closing brackets or a semicolon after it: e.g. not `a.toString() + password`.
173
+ function wholeCall(text) {
174
+ const m = text.match(/^\s*(?:await\s+)?(?:new\s+)?[\w$.]+\s*\(/);
175
+ if (!m) return false;
176
+ let i = m[0].length;
177
+ for (;;) {
178
+ let depth = 1;
179
+ for (; i < text.length && depth > 0; i++) {
180
+ if (text[i] === '(') depth++;
181
+ else if (text[i] === ')') depth--;
182
+ }
183
+ // Chained calls (`.then(...)`, `.catch(...)`) keep it a call result.
184
+ const chain = text.slice(i).match(/^\s*\??\.\s*[\w$]+\s*\(/);
185
+ if (!chain) break;
186
+ i += chain[0].length;
187
+ }
188
+ return /^[\s);,]*$/.test(text.slice(i));
189
+ }
190
+
191
+ // Text between the opening parenthesis of the first `re` match and its matching close.
192
+ function callArguments(text, re) {
193
+ const m = text.match(re);
194
+ if (!m) return '';
195
+ let i = m.index + m[0].length;
196
+ let depth = 1;
197
+ const start = i;
198
+ for (; i < text.length && depth > 0; i++) {
199
+ if (text[i] === '(') depth++;
200
+ else if (text[i] === ')') depth--;
201
+ }
202
+ return text.slice(start, i - 1);
203
+ }
204
+
205
+ // WebView settings that let page content read local files or mix HTTP into HTTPS pages.
206
+ export function webViewRisks(root) {
207
+ const findings = [];
208
+ for (const file of jsFiles(root)) {
209
+ const text = read(file);
210
+ if (!text || !/react-native-webview|<WebView\b/.test(text)) continue;
211
+ const rel = path.relative(root, file);
212
+ const lineOf = (re) => text.slice(0, text.search(re)).split('\n').length;
213
+ const universal = /allowUniversalAccessFromFileURLs\s*(?:=\s*\{\s*true\s*\}|(?=[\s/>]))/;
214
+ const fileAccess = /allowFileAccessFromFileURLs\s*(?:=\s*\{\s*true\s*\}|(?=[\s/>]))/;
215
+ if (universal.test(text) || fileAccess.test(text)) {
216
+ const re = universal.test(text) ? universal : fileAccess;
217
+ findings.push({
218
+ id: `webview-file-access:${rel}`,
219
+ severity: 'high',
220
+ area: 'security',
221
+ title: `WebView lets page scripts read local files (${rel}:${lineOf(re)})`,
222
+ detail: 'allowUniversalAccessFromFileURLs / allowFileAccessFromFileURLs let JavaScript in a file:// page read other files and call any origin. Combined with any injected or remote content this exposes app data. Remove them unless you load only your own bundled files.',
223
+ fix: { kind: 'security', step: `In \`${rel}\`, remove \`allowUniversalAccessFromFileURLs\` / \`allowFileAccessFromFileURLs\` from the WebView.` },
224
+ });
225
+ }
226
+ if (/mixedContentMode\s*=\s*\{?\s*['"]always['"]/.test(text)) {
227
+ findings.push({
228
+ id: `webview-mixed-content:${rel}`,
229
+ severity: 'medium',
230
+ area: 'security',
231
+ title: `WebView allows HTTP content inside HTTPS pages (${rel}:${lineOf(/mixedContentMode/)})`,
232
+ detail: 'mixedContentMode="always" lets insecure scripts and frames load into secure pages on Android, where a network attacker can change them. Use "never", or "compatibility" if a legacy page needs it.',
233
+ fix: { kind: 'security', step: `In \`${rel}\`, set \`mixedContentMode="never"\`.` },
234
+ });
235
+ }
236
+ }
237
+ return findings;
238
+ }
239
+
240
+ // android:allowBackup="true" without backup rules: app data (tokens in SharedPreferences,
241
+ // AsyncStorage, databases) can be copied off the device with adb or restored to another one.
242
+ export function androidBackup(root) {
243
+ const file = path.join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
244
+ const text = read(file);
245
+ if (!text) return [];
246
+ const app = text.match(/<application\b[^>]*>/s);
247
+ if (!app || !/android:allowBackup\s*=\s*"true"/.test(app[0])) return [];
248
+ if (/android:(?:dataExtractionRules|fullBackupContent)\s*=\s*"@/.test(app[0])) return [];
249
+ return [
250
+ {
251
+ id: 'android-allow-backup',
252
+ severity: 'medium',
253
+ area: 'security',
254
+ title: 'Android backups include all app data (allowBackup="true")',
255
+ detail: 'Without backup rules, tokens and databases are copied by device and cloud backups and can be restored on another device. The React Native template sets allowBackup="false"; set it back, or add dataExtractionRules that exclude credentials.',
256
+ fix: { kind: 'security', step: 'Set `android:allowBackup="false"` in `android/app/src/main/AndroidManifest.xml`, or add `dataExtractionRules` that exclude credentials.' },
257
+ },
258
+ ];
259
+ }
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { cleanVersion, findPackageDir, installedVersion } from './project.js';
4
+ import { filesImportingWith } from './usage.js';
5
+
6
+ // Static checks for setups that build fine and then crash at runtime. Each one is a known,
7
+ // documented failure, not a style preference.
8
+
9
+ const readText = (file) => {
10
+ try {
11
+ return fs.readFileSync(file, 'utf8');
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+
17
+ const majorOf = (v) => (v ? Number(String(v).split('.')[0]) : null);
18
+
19
+ // The app's Babel config, or the workspace root's (monorepos keep babel.config.js at the root).
20
+ // null when none is found: then nothing can be said about plugins.
21
+ function babelConfig(root, pkgJson) {
22
+ const files = ['babel.config.js', 'babel.config.cjs', 'babel.config.mjs', 'babel.config.json', '.babelrc', '.babelrc.js', '.babelrc.json'];
23
+ let dir = root;
24
+ let texts = [];
25
+ while (true) {
26
+ texts = files.map((f) => readText(path.join(dir, f))).filter(Boolean);
27
+ if (texts.length || fs.existsSync(path.join(dir, '.git'))) break;
28
+ const parent = path.dirname(dir);
29
+ if (parent === dir) break;
30
+ dir = parent;
31
+ }
32
+ root = dir;
33
+ if (pkgJson.babel) texts.push(JSON.stringify(pkgJson.babel));
34
+ // Shared config pulled in with require('./config/babel-shared') and the like.
35
+ for (const t of [...texts]) {
36
+ for (const m of t.matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) {
37
+ const base = path.join(root, m[1]);
38
+ const text = [base, `${base}.js`, `${base}.cjs`, `${base}.json`].map(readText).find(Boolean);
39
+ if (text) texts.push(text);
40
+ }
41
+ }
42
+ return texts.length ? texts.join('\n') : null;
43
+ }
44
+
45
+ export function stabilityChecks(project) {
46
+ const findings = [];
47
+ const add = (f) => findings.push(f);
48
+ const root = project.root;
49
+ const pkgJson = JSON.parse(readText(path.join(root, 'package.json')) || '{}');
50
+ const deps = { ...project.devDeps, ...project.deps };
51
+ const versionOf = (name) => cleanVersion(installedVersion(root, name) || deps[name] || '');
52
+
53
+ // Reanimated needs its Babel plugin: without it, worklets are not compiled and the first
54
+ // animation throws ("Failed to create a worklet" / "Reanimated 2 failed to create a worklet").
55
+ // babel-preset-expo adds it automatically.
56
+ if (deps['react-native-reanimated']) {
57
+ const rea = versionOf('react-native-reanimated');
58
+ const babel = babelConfig(root, pkgJson);
59
+ const expoPreset = babel ? /babel-preset-expo/.test(babel) : !!project.expoVersion;
60
+ const hasPlugin = !babel || /react-native-reanimated\/plugin|react-native-worklets\/plugin/.test(babel);
61
+ // Reanimated 2 also runs the v1 API, which needs no plugin: only flag it when the app uses
62
+ // worklet APIs. From 3 on the plugin is always required.
63
+ const needsPlugin =
64
+ majorOf(rea) >= 3 ||
65
+ (majorOf(rea) === 2 && filesImportingWith(root, 'react-native-reanimated', "useSharedValue|useAnimatedStyle|useDerivedValue|useAnimatedScrollHandler|runOnJS|runOnUI|['\"]worklet['\"]").length > 0);
66
+ if (!expoPreset && !hasPlugin && needsPlugin) {
67
+ const plugin = majorOf(rea) >= 4 ? 'react-native-worklets/plugin' : 'react-native-reanimated/plugin';
68
+ add({
69
+ id: 'crash-reanimated-babel',
70
+ severity: 'high',
71
+ area: 'crash',
72
+ title: `Reanimated ${rea} without its Babel plugin`,
73
+ detail: `Worklets are compiled by \`${plugin}\`; without it the first animation throws at runtime ("Failed to create a worklet"). Add it as the last entry of \`plugins\` in your Babel config, then restart Metro with \`--reset-cache\`.`,
74
+ fix: { kind: 'crash', step: `Add \`'${plugin}'\` as the last Babel plugin, then \`npx react-native start --reset-cache\`.` },
75
+ });
76
+ }
77
+ // Reanimated 4 moved worklets into a separate package that must be installed by the app.
78
+ if (majorOf(rea) >= 4 && !deps['react-native-worklets']) {
79
+ add({
80
+ id: 'crash-reanimated-worklets',
81
+ severity: 'high',
82
+ area: 'crash',
83
+ title: `Reanimated ${rea} needs react-native-worklets`,
84
+ detail: 'Reanimated 4 depends on react-native-worklets as a peer: without it the native build fails or the app crashes on start. Install the version Reanimated\'s compatibility table lists for your React Native version.',
85
+ fix: { kind: 'crash', step: `Install \`react-native-worklets\` (${project.expoVersion ? '`npx expo install react-native-worklets`' : 'the version that matches Reanimated'}).` },
86
+ });
87
+ }
88
+ }
89
+
90
+ // React Native Firebase: every @react-native-firebase/* package must be on the same version.
91
+ // Mixed versions fail to build or crash on start (native SDK version conflicts).
92
+ const rnfb = Object.keys(deps).filter((n) => n.startsWith('@react-native-firebase/'));
93
+ if (rnfb.length > 1) {
94
+ const versions = Object.fromEntries(rnfb.map((n) => [n, versionOf(n)]).filter(([, v]) => v));
95
+ const majors = new Set(Object.values(versions).map(majorOf));
96
+ if (majors.size > 1) {
97
+ add({
98
+ id: 'crash-firebase-versions',
99
+ severity: 'high',
100
+ area: 'crash',
101
+ title: 'React Native Firebase packages are on different major versions',
102
+ detail: `${Object.entries(versions).map(([n, v]) => `${n} ${v}`).join(', ')}. They share one native Firebase SDK and must all be on the same version; mixed majors fail to build or crash at startup.`,
103
+ fix: { kind: 'crash', step: 'Move every `@react-native-firebase/*` package to the same version (the same as `@react-native-firebase/app`).' },
104
+ });
105
+ }
106
+ }
107
+
108
+ // React Navigation: navigators and @react-navigation/native must share a major version.
109
+ // elements, devtools, web and compat follow their own version numbers.
110
+ const navOwnVersions = new Set(['@react-navigation/elements', '@react-navigation/devtools', '@react-navigation/web', '@react-navigation/compat']);
111
+ const nav = Object.keys(deps).filter((n) => n.startsWith('@react-navigation/') && !navOwnVersions.has(n));
112
+ if (nav.length > 1) {
113
+ const versions = Object.fromEntries(nav.map((n) => [n, versionOf(n)]).filter(([, v]) => v));
114
+ const majors = new Set(Object.values(versions).map(majorOf));
115
+ if (majors.size > 1) {
116
+ add({
117
+ id: 'crash-navigation-versions',
118
+ severity: 'high',
119
+ area: 'crash',
120
+ title: 'React Navigation packages are on different major versions',
121
+ detail: `${Object.entries(versions).map(([n, v]) => `${n} ${v}`).join(', ')}. Navigators from one major do not work with the core of another; this usually crashes on the first navigation ("Couldn't find a navigation context" or undefined functions).`,
122
+ fix: { kind: 'crash', step: 'Move every `@react-navigation/*` package to the same major version, following its migration guide.' },
123
+ });
124
+ }
125
+ }
126
+
127
+ // A second copy of react or react-native inside a dependency: "Invalid hook call" or
128
+ // duplicate native module registration at runtime.
129
+ if (project.hasNodeModules) {
130
+ for (const core of ['react', 'react-native']) {
131
+ const rootVersion = installedVersion(root, core);
132
+ const nm = findPackageDir(root, core) ? path.dirname(findPackageDir(root, core)) : null;
133
+ if (!nm || !rootVersion) continue;
134
+ const copies = [];
135
+ // Only runtime dependencies end up in the bundle (Storybook and @types copies do not).
136
+ const runtime = new Set(Object.keys(project.deps).filter((n) => !n.startsWith('@types/')));
137
+ for (const dep of listPackages(nm).filter((d) => runtime.has(d))) {
138
+ const nested = path.join(nm, dep, 'node_modules', core, 'package.json');
139
+ const text = readText(nested);
140
+ if (!text) continue;
141
+ try {
142
+ const v = JSON.parse(text).version;
143
+ if (v && v !== rootVersion) copies.push(`${dep} (${v})`);
144
+ } catch {
145
+ // unreadable
146
+ }
147
+ }
148
+ if (copies.length) {
149
+ add({
150
+ id: `crash-duplicate-${core}`,
151
+ severity: 'high',
152
+ area: 'crash',
153
+ title: `A second copy of ${core} is installed inside ${copies.length === 1 ? 'a dependency' : `${copies.length} dependencies`}`,
154
+ detail: `${copies.slice(0, 5).join(', ')}${copies.length > 5 ? ', …' : ''}, next to ${core} ${rootVersion}. ${core === 'react' ? 'Two Reacts in one bundle crash with "Invalid hook call".' : 'Two copies of React Native register native modules twice and fail at startup.'} Align the dependency's version or deduplicate (resolutions/overrides).`,
155
+ fix: { kind: 'crash', step: `Remove the nested ${core} copies: align versions or add an \`overrides\`/\`resolutions\` entry for \`${core}\`, reinstall, and check with \`npm ls ${core}\`.` },
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ return findings;
162
+ }
163
+
164
+ // Top-level package folder names in a node_modules directory (scoped ones as @scope/name).
165
+ function listPackages(nm) {
166
+ const out = [];
167
+ let entries = [];
168
+ try {
169
+ entries = fs.readdirSync(nm, { withFileTypes: true });
170
+ } catch {
171
+ return out;
172
+ }
173
+ for (const e of entries) {
174
+ if (e.name.startsWith('.')) continue;
175
+ if (e.name.startsWith('@')) {
176
+ try {
177
+ for (const sub of fs.readdirSync(path.join(nm, e.name))) out.push(`${e.name}/${sub}`);
178
+ } catch {
179
+ // unreadable scope
180
+ }
181
+ } else {
182
+ out.push(e.name);
183
+ }
184
+ }
185
+ return out;
186
+ }