nativekeel 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.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)
9
+ **What's new in 0.1.9:** Android components open to every app, API calls over plain HTTP, and correct plans for old Expo projects. Since 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
10
 
11
11
  It answers the questions that decide whether your app can still ship:
12
12
 
@@ -18,7 +18,7 @@ It answers the questions that decide whether your app can still ship:
18
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.
19
19
  - **Is transport security off?** Cleartext HTTP or `debuggable` in the release Android manifest, `NSAllowsArbitraryLoads` on iOS.
20
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.
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, Android components any app can start, API calls over plain HTTP.
22
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.
23
23
  - **Leftovers from old templates?** Flipper, JavaScriptCore instead of Hermes.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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
@@ -1,11 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { cleanVersion, findPackageDir, installedVersion } from './project.js';
3
+ import { cleanVersion, findPackageDir, installedVersion, versionFromSpec } 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
7
  import { stabilityChecks } from './stability.js';
8
- import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup } from './security.js';
8
+ import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls } from './security.js';
9
9
  import { lockedVersions } from './lockfile.js';
10
10
  import { findUnused, filesImportingWith } from './usage.js';
11
11
  import { matchKnownIssues, compareVersions } from './known-issues.js';
@@ -226,7 +226,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
226
226
  const daysSince = (date) => (date ? Math.floor((now - new Date(`${date}T00:00:00Z`)) / 86400000) : null);
227
227
 
228
228
  const nativeOf = Object.fromEntries(names.map((n) => [n, isNativeModule(project.root, n, directory[n])]));
229
- const versionOf = (n) => cleanVersion(installedVersion(project.root, n) || declaredRange(n));
229
+ const versionOf = (n) => cleanVersion(installedVersion(project.root, n) || versionFromSpec(n, declaredRange(n)) || '');
230
230
  const archSuspects = names.filter((n) => nativeOf[n] && directory[n] && directory[n].newArchitecture === false);
231
231
  const codegen = forcedOffline ? {} : await registry.codegenSupport(archSuspects.map((n) => ({ name: n, version: versionOf(n) })));
232
232
 
@@ -405,7 +405,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
405
405
  for (const f of stabilityChecks(project)) add(f);
406
406
 
407
407
  // Security beyond leaked keys.
408
- for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root)]) add(f);
408
+ for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root), ...exportedComponents(project.root), ...plainHttpCalls(project.root)]) add(f);
409
409
  if (!forcedOffline) {
410
410
  // Exact versions only (installed, else the lockfile): a range would be a guess.
411
411
  const locked = lockedVersions(project.root, project.deps);
package/src/plan.js CHANGED
@@ -177,7 +177,7 @@ export function buildPlan(result) {
177
177
  // In Expo projects `npx expo install` picks the version that matches the SDK.
178
178
  const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
179
179
  const pinned = isExpo ? expoPinnedNames(result.project.root) : new Set();
180
- const movesWithExpo = (name) => isExpo && (/^(expo-|@expo\/)/.test(name) || name === 'expo' || name === 'jest-expo' || pinned.has(name));
180
+ const movesWithExpo = (name) => isExpo && (/^(expo-|@expo\/)/.test(name) || ['expo', 'jest-expo', 'react-native', 'react', 'react-dom'].includes(name) || pinned.has(name));
181
181
  const installed = new Set(result.deps.map((d) => d.name));
182
182
  const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
183
183
  const phases = [];
@@ -481,8 +481,18 @@ export function buildPlan(result) {
481
481
  const expo = byKind('upgrade-expo')[0];
482
482
  if (expo) {
483
483
  const steps = [];
484
+ if (expo.to - expo.from >= 6) {
485
+ steps.push(`**${expo.to - expo.from} SDKs is a long way.** Weigh it against starting a fresh project on SDK ${expo.to} (\`npx create-expo-app\`) and moving your screens, assets and app config over: with few custom native modules that is often faster than ${expo.to - expo.from} upgrades.`);
486
+ }
484
487
  for (let v = expo.from + 1; v <= expo.to; v++) {
485
- steps.push(`SDK ${v - 1} → ${v}: \`npx expo install expo@^${v}.0.0 --fix\`, read the SDK ${v} changelog, run \`npx expo-doctor\`, build.`);
488
+ // The local Expo CLI (`npx expo`) exists from SDK 46; older SDKs upgrade with expo-cli.
489
+ steps.push(
490
+ v < 46
491
+ ? `SDK ${v - 1} → ${v}: \`npx expo-cli upgrade ${v}\` (the legacy CLI; the local \`npx expo\` CLI arrives with SDK 46), read the SDK ${v} changelog, build.`
492
+ : v === 46
493
+ ? `SDK 45 → 46: \`${basePm.add} expo@^46.0.0\`, then \`npx expo install --fix\` (the first SDK with the local CLI), read the SDK 46 changelog, build.`
494
+ : `SDK ${v - 1} → ${v}: \`npx expo install expo@^${v}.0.0 --fix\`, read the SDK ${v} changelog, run \`npx expo-doctor\`, build.`,
495
+ );
486
496
  // SDK 52 ships React Native 0.76, the first version where switching is practical.
487
497
  if (deferredArch && v >= 52) {
488
498
  steps.push(`**At SDK ${v}, turn on the New Architecture** before going further (it is the default from SDK 53, and React Native 0.${NEW_ARCH_ONLY_MINOR} removed the legacy one):`, ...deferredArch.map((x) => `New Architecture: ${x}`));
package/src/project.js CHANGED
@@ -99,6 +99,23 @@ function resolveCatalogRefs(deps, catalogs) {
99
99
  return out;
100
100
  }
101
101
 
102
+ // Expo SDKs up to 44 installed React Native from Expo's fork, declared as a tarball URL such as
103
+ // https://github.com/expo/react-native/archive/sdk-37.0.1.tar.gz. The number there is the SDK,
104
+ // not React Native. React Native version per SDK, from Expo's SDK history.
105
+ const EXPO_FORK_RN = { 31: '0.57.1', 32: '0.57.1', 33: '0.59.8', 34: '0.59.8', 35: '0.59.8', 36: '0.61.4', 37: '0.61.4', 38: '0.62.2', 39: '0.63.2', 40: '0.63.2', 41: '0.63.2', 42: '0.63.2', 43: '0.64.3', 44: '0.64.3' };
106
+
107
+ // A version number from a dependency spec, or null when the spec is a URL or git reference
108
+ // whose numbers are not the package version.
109
+ export function versionFromSpec(name, spec) {
110
+ if (!spec) return null;
111
+ const s = String(spec);
112
+ if (/:\/\/|^(github|git|file|link|workspace):|#|\.tgz$|\.tar\.gz$/.test(s)) {
113
+ const sdk = name === 'react-native' && s.match(/expo\/react-native\/archive\/sdk-(\d+)/);
114
+ return sdk && EXPO_FORK_RN[Number(sdk[1])] ? EXPO_FORK_RN[Number(sdk[1])] : null;
115
+ }
116
+ return s;
117
+ }
118
+
102
119
  export function loadProject(root) {
103
120
  const pkg = readJson(path.join(root, 'package.json'));
104
121
  if (!pkg) throw new Error(`No package.json found in ${root}`);
@@ -128,7 +145,7 @@ export function loadProject(root) {
128
145
  isLibrary,
129
146
  deps,
130
147
  devDeps,
131
- rnVersion: cleanVersion(installedVersion(root, 'react-native') || declared('react-native') || ''),
148
+ rnVersion: cleanVersion(installedVersion(root, 'react-native') || versionFromSpec('react-native', declared('react-native')) || ''),
132
149
  expoVersion: declared('expo') ? cleanVersion(installedVersion(root, 'expo') || declared('expo')) : null,
133
150
  // Expo "managed" apps generate android/ and ios/ at build time (Continuous Native Generation).
134
151
  managed: !!declared('expo') && !hasAndroid && !hasIos,
package/src/security.js CHANGED
@@ -19,7 +19,7 @@ function* jsFiles(dir) {
19
19
  for (const e of entries) {
20
20
  if (e.isDirectory()) {
21
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)) {
22
+ } else if (JS_EXT.test(e.name) && !/\.(test|spec)[._]/.test(e.name)) {
23
23
  yield path.join(dir, e.name);
24
24
  }
25
25
  }
@@ -257,3 +257,79 @@ export function androidBackup(root) {
257
257
  },
258
258
  ];
259
259
  }
260
+
261
+ const SYSTEM_ACTIONS = new Set([
262
+ 'android.appwidget.action.APPWIDGET_UPDATE',
263
+ 'android.intent.action.BOOT_COMPLETED',
264
+ 'android.intent.action.LOCKED_BOOT_COMPLETED',
265
+ 'android.intent.action.QUICKBOOT_POWERON',
266
+ 'com.htc.intent.action.QUICKBOOT_POWERON',
267
+ 'android.intent.action.MY_PACKAGE_REPLACED',
268
+ 'android.intent.action.PACKAGE_REPLACED',
269
+ 'android.intent.action.DOWNLOAD_COMPLETE',
270
+ 'android.intent.action.REBOOT',
271
+ ]);
272
+
273
+ // Services, receivers and providers in the app's own manifest that other apps can start or
274
+ // query: exported without a permission. (Launcher activities are meant to be exported.)
275
+ export function exportedComponents(root) {
276
+ const file = path.join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
277
+ const text = read(file);
278
+ if (!text) return [];
279
+ const open = [];
280
+ for (const m of text.matchAll(/<(service|receiver|provider)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g)) {
281
+ const attrs = m[2];
282
+ if (!/android:exported\s*=\s*"true"/.test(attrs) || /android:(?:permission|readPermission|writePermission)\s*=/.test(attrs)) continue;
283
+ // Widgets and receivers for system broadcasts (boot, package replaced, download complete)
284
+ // have to be exported for Android to reach them.
285
+ const actions = [...(m[3] || '').matchAll(/<action\s+android:name\s*=\s*"([^"]+)"/g)].map((a) => a[1]);
286
+ const isWidget = /android\.appwidget\.provider/.test(m[3] || '');
287
+ if (isWidget || (actions.length && actions.every((a) => SYSTEM_ACTIONS.has(a) || (m[1] === 'receiver' && /^android\.(?:intent\.action|appwidget\.action)\./.test(a))))) continue;
288
+ const name = (attrs.match(/android:name\s*=\s*"([^"]+)"/) || [])[1] || m[1];
289
+ open.push(`${m[1]} ${name}`);
290
+ }
291
+ if (!open.length) return [];
292
+ return [
293
+ {
294
+ id: 'android-exported-components',
295
+ severity: 'medium',
296
+ area: 'security',
297
+ title: `${open.length} Android component${open.length === 1 ? '' : 's'} open to every other app`,
298
+ detail: `${open.slice(0, 4).join(', ')}${open.length > 4 ? ', …' : ''}: exported="true" without a permission, so any installed app can start it or read from it. Set exported="false" unless another app must call it, or protect it with android:permission.`,
299
+ fix: { kind: 'security', step: 'In `android/app/src/main/AndroidManifest.xml`, set `android:exported="false"` on services/receivers/providers no other app needs, or add `android:permission`.' },
300
+ },
301
+ ];
302
+ }
303
+
304
+ // API calls over plain HTTP/WS to a real host. Android (API 28+) and iOS block these unless
305
+ // security is turned off, so they either fail in production or travel unencrypted.
306
+ const LOCAL_HOST = /^(?:localhost|127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|0\.0\.0\.0|\[?::1)/;
307
+ export function plainHttpCalls(root) {
308
+ const hosts = new Map();
309
+ for (const file of jsFiles(root)) {
310
+ const text = read(file);
311
+ if (!text || !/(?:http|ws):\/\//.test(text)) continue;
312
+ const lines = text.split('\n');
313
+ lines.forEach((line, i) => {
314
+ if (/Linking\.|openURL|openBrowser|WebBrowser\.|href=|xmlns|schemas\.|w3\.org|example\.(?:com|org)/.test(line)) return;
315
+ if (!/\bfetch\s*\(|axios|baseURL|baseUrl|BASE_URL|API_URL|apiUrl|new WebSocket\s*\(|\.get\s*\(|\.post\s*\(/.test(line)) return;
316
+ for (const m of line.matchAll(/['"`](?:http|ws):\/\/([^/'"`:\s$]+)/g)) {
317
+ // Real hosts only: not local addresses, not placeholders like http://dummy used for URL parsing.
318
+ if (LOCAL_HOST.test(m[1]) || /^\$\{/.test(m[1]) || !m[1].includes('.')) continue;
319
+ if (!hosts.has(m[1])) hosts.set(m[1], `${path.relative(root, file)}:${i + 1}`);
320
+ }
321
+ });
322
+ }
323
+ if (!hosts.size) return [];
324
+ const list = [...hosts].map(([h, at]) => `${h} (${at})`);
325
+ return [
326
+ {
327
+ id: 'plain-http-calls',
328
+ severity: 'medium',
329
+ area: 'security',
330
+ title: `API calls over plain HTTP to ${hosts.size} host${hosts.size === 1 ? '' : 's'}`,
331
+ detail: `${list.slice(0, 4).join(', ')}${list.length > 4 ? ', …' : ''}. Anyone on the same network can read or change this traffic, and Android and iOS block it unless cleartext is allowed. Use https:// (wss:// for sockets).`,
332
+ fix: { kind: 'security', step: `Switch ${[...hosts.keys()].slice(0, 3).map((h) => `\`${h}\``).join(', ')}${hosts.size > 3 ? ' and the others' : ''} to https:// (wss:// for sockets), then remove any cleartext exceptions you added for them.` },
333
+ },
334
+ ];
335
+ }
package/src/stability.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { cleanVersion, findPackageDir, installedVersion } from './project.js';
3
+ import { cleanVersion, findPackageDir, installedVersion, versionFromSpec } from './project.js';
4
4
  import { filesImportingWith } from './usage.js';
5
5
 
6
6
  // Static checks for setups that build fine and then crash at runtime. Each one is a known,
@@ -48,7 +48,7 @@ export function stabilityChecks(project) {
48
48
  const root = project.root;
49
49
  const pkgJson = JSON.parse(readText(path.join(root, 'package.json')) || '{}');
50
50
  const deps = { ...project.devDeps, ...project.deps };
51
- const versionOf = (name) => cleanVersion(installedVersion(root, name) || deps[name] || '');
51
+ const versionOf = (name) => cleanVersion(installedVersion(root, name) || versionFromSpec(name, deps[name]) || '');
52
52
 
53
53
  // Reanimated needs its Babel plugin: without it, worklets are not compiled and the first
54
54
  // animation throws ("Failed to create a worklet" / "Reanimated 2 failed to create a worklet").