nativekeel 0.1.16 → 0.1.17

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.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)
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.17",
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';
@@ -232,6 +233,8 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
232
233
  const codegen = forcedOffline ? {} : await registry.codegenSupport(archSuspects.map((n) => ({ name: n, version: versionOf(n) })));
233
234
 
234
235
  const deps = [];
236
+ let pinnedCache = null;
237
+ const expoPinned = () => (pinnedCache ||= expoPinnedNames(project.root));
235
238
  for (const name of names) {
236
239
  const dir = directory[name];
237
240
  const version = versionOf(name);
@@ -296,13 +299,15 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
296
299
  });
297
300
  }
298
301
  if (!noNewArch && !dep.unmaintained && !stale && version && dep.latest && lineOf(version) !== lineOf(dep.latest)) {
302
+ // In Expo apps the SDK decides these versions: the SDK upgrade moves them, no separate work.
303
+ const expoManaged = !!project.expoVersion && movesWithExpoSdk(name, expoPinned());
299
304
  add({
300
305
  id: `dep-major:${name}`,
301
- severity: 'low',
306
+ severity: expoManaged ? 'info' : 'low',
302
307
  area: 'dependency',
303
308
  title: `${name} ${version} → ${dep.latest}`,
304
- detail: 'Major version behind.',
305
- fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native },
309
+ detail: expoManaged ? 'Major version behind; the Expo SDK upgrade moves it.' : 'Major version behind.',
310
+ fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native, expoManaged },
306
311
  });
307
312
  }
308
313
  }
@@ -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,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 { expoPinnedNames, movesWithExpoSdk } from './expo-pins.js';
6
7
 
7
8
  // Turns a scan result into an ordered upgrade plan. The order matters more than the steps:
8
9
  // stop leaks first, then remove blockers, switch architecture on the current version,
@@ -145,40 +146,13 @@ function safetyNetPhase(tool) {
145
146
  };
146
147
  }
147
148
 
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;
169
- }
170
- } catch {
171
- return EXPO_PINNED;
172
- }
173
- }
174
-
175
149
  export function buildPlan(result) {
176
150
  const isExpo = !!result.project.expo;
177
151
  const basePm = commands[packageManager(result.project.root)];
178
152
  // In Expo projects `npx expo install` picks the version that matches the SDK.
179
153
  const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
180
154
  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));
155
+ const movesWithExpo = (name) => isExpo && movesWithExpoSdk(name, pinned);
182
156
  const installed = new Set(result.deps.map((d) => d.name));
183
157
  const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
184
158
  const phases = [];
@@ -289,7 +263,7 @@ export function buildPlan(result) {
289
263
  sec.push({ sev, step: f.fix.step });
290
264
  } else if (f.fix.kind === 'vuln-dep' && (f.fix.severity === 'critical' || f.fix.severity === 'high')) {
291
265
  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.'}` });
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.'}` });
293
267
  }
294
268
  }
295
269
  const securitySteps = sec.sort((a, b) => a.sev - b.sev).map((x) => x.step);
@@ -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,7 +33,7 @@ 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
 
@@ -65,8 +65,8 @@ export async function vulnerableDependencies(registry, versions) {
65
65
  id: `vuln:${name}`,
66
66
  severity,
67
67
  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).`,
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
70
  fix: { kind: 'vuln-dep', name, from: version, to: fixedIn, severity },
71
71
  });
72
72
  }
@@ -87,15 +87,17 @@ function applies(version, range) {
87
87
  });
88
88
  }
89
89
 
90
+ // The lowest version outside every advisory's range. "<1.2.3" is fixed in 1.2.3; "<=1.2.2" only
91
+ // says "after 1.2.2", which counts when it is the highest bound.
90
92
  function minimumFixed(advisories) {
91
93
  let fixed = null;
92
94
  for (const a of advisories) {
93
- const bounds = [...String(a.vulnerable_versions || '').matchAll(/<\s*(\d+\.\d+\.\d+)/g)].map((m) => m[1]);
95
+ const bounds = [...String(a.vulnerable_versions || '').matchAll(/<(=?)\s*(\d+\.\d+\.\d+)/g)].map((m) => ({ v: m[2], after: m[1] === '=' }));
94
96
  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;
97
+ const hi = bounds.sort((x, y) => compareVersions(x.v, y.v) || (x.after ? 1 : -1)).at(-1);
98
+ if (!fixed || compareVersions(hi.v, fixed.v) > 0 || (compareVersions(hi.v, fixed.v) === 0 && hi.after)) fixed = hi;
97
99
  }
98
- return fixed;
100
+ return fixed && (fixed.after ? `a release after ${fixed.v}` : fixed.v);
99
101
  }
100
102
 
101
103
  // A password (or the variables built from it) passed to a log, crash report, analytics event,