nativekeel 0.1.8 → 0.1.10
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 +3 -3
- package/package.json +1 -1
- package/src/analyze.js +4 -4
- package/src/plan.js +32 -2
- package/src/project.js +18 -1
- package/src/report-html.js +3 -0
- package/src/report-text.js +7 -1
- package/src/security.js +77 -1
- package/src/stability.js +64 -2
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.
|
|
9
|
+
**What's new in 0.1.10:** every report opens with *Start here*, the three most urgent first steps; missing iOS permission texts (a crash and an App Review rejection) are caught. Recent releases added known vulnerabilities in the exact versions you ship, password leaks into logs and databases, unsafe WebViews, open Android components, plain-HTTP API calls and runtime crash risks. 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,8 +18,8 @@ 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.
|
|
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.
|
|
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
|
+
- **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, camera/photos/location access without the Info.plist description iOS requires.
|
|
23
23
|
- **Leftovers from old templates?** Flipper, JavaScriptCore instead of Hermes.
|
|
24
24
|
|
|
25
25
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nativekeel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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) ||
|
|
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
|
-
|
|
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}`));
|
|
@@ -532,6 +542,11 @@ export function buildPlan(result) {
|
|
|
532
542
|
});
|
|
533
543
|
}
|
|
534
544
|
|
|
545
|
+
// Store deadlines sit together, right after the target SDK.
|
|
546
|
+
const storeAt = phases.findIndex((p) => p.title === 'Meet App Store and Google Play requirements');
|
|
547
|
+
const targetAt = phases.findIndex((p) => p.title === 'Meet the Google Play target SDK');
|
|
548
|
+
if (storeAt > targetAt + 1 && targetAt >= 0) phases.splice(targetAt + 1, 0, ...phases.splice(storeAt, 1));
|
|
549
|
+
|
|
535
550
|
// Only worth it when the plan changes code: secrets alone do not need UI tests.
|
|
536
551
|
const leaksFirst = phases[0] && phases[0].title === 'Stop the leaks' ? 1 : 0;
|
|
537
552
|
if (phases.length > leaksFirst) {
|
|
@@ -572,3 +587,18 @@ export function planToMarkdown(result, plan) {
|
|
|
572
587
|
lines.push('---', `No time to do it yourself? We do fixed-price upgrades on a branch of your repo: ${SITE_URL}/#services`);
|
|
573
588
|
return `${lines.join('\n')}\n`;
|
|
574
589
|
}
|
|
590
|
+
|
|
591
|
+
// The first concrete step of the first few phases: what to do today. Shown at the top of the
|
|
592
|
+
// HTML report and under the summary in the terminal.
|
|
593
|
+
const URGENCY = ['Stop the leaks', 'Fix crash risks', 'Meet the Google Play target SDK', 'Meet App Store and Google Play requirements', 'Close security gaps', 'Avoid known traps'];
|
|
594
|
+
|
|
595
|
+
export function startHere(result, count = 3) {
|
|
596
|
+
const skip = new Set(['Set up a safety net', 'Verify before you ship']);
|
|
597
|
+
const rank = (t) => (URGENCY.indexOf(t) >= 0 ? URGENCY.indexOf(t) : URGENCY.length);
|
|
598
|
+
return buildPlan(result)
|
|
599
|
+
.phases.filter((p) => !skip.has(p.title) && p.steps.length)
|
|
600
|
+
.map((p, i) => ({ p, i }))
|
|
601
|
+
.sort((a, b) => rank(a.p.title) - rank(b.p.title) || a.i - b.i)
|
|
602
|
+
.slice(0, count)
|
|
603
|
+
.map(({ p }) => ({ phase: p.title, step: p.steps[0].replace(/ The [^.]* phase below gets you there;.*$/, '') }));
|
|
604
|
+
}
|
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/report-html.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SEVERITIES, countBySeverity } from './analyze.js';
|
|
2
2
|
import { SITE_URL } from './config.js';
|
|
3
|
+
import { startHere } from './plan.js';
|
|
3
4
|
|
|
4
5
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
5
6
|
// Turns `code` spans from finding text into <code>.
|
|
@@ -58,6 +59,7 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:32px 0 12px}h2 small{f
|
|
|
58
59
|
.stat.critical b{color:var(--critical)}.stat.high b{color:var(--high)}
|
|
59
60
|
.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}
|
|
60
61
|
ul.list{list-style:none;padding:0;margin:0;display:grid;gap:8px}
|
|
62
|
+
.start{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:8px;padding:6px 18px 10px;margin-top:20px}.start h2{margin:10px 0 6px}.start li{margin:6px 0}
|
|
61
63
|
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}
|
|
62
64
|
.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}
|
|
63
65
|
.f p{margin:2px 0 0;color:var(--muted);font-size:14px}.f strong{overflow-wrap:anywhere}
|
|
@@ -88,6 +90,7 @@ ${p.expo ? `<span>Expo SDK <b>${esc(p.expo.split('.')[0])}</b></span>` : ''}
|
|
|
88
90
|
${p.android && p.android.targetSdk ? `<span>targetSdk <b>${p.android.targetSdk}</b></span>` : ''}
|
|
89
91
|
<span>${result.deps.length} dependencies (${result.deps.filter((d) => d.native).length} native)</span>
|
|
90
92
|
</div>
|
|
93
|
+
${n.critical + n.high > 0 ? `<section class="start"><h2>Start here</h2><ol>${startHere(result).map((x) => `<li>${rich(x.step)} <span class="muted">(${esc(x.phase)})</span></li>`).join('')}</ol><p class="muted">The full, ordered plan: <code>npx nativekeel plan</code>.</p></section>` : ''}
|
|
91
94
|
<h2>Findings</h2>
|
|
92
95
|
${result.findings.length ? `${rows ? `<ul class="list">${rows}</ul>` : ''}${bumpTable}` : '<p>No issues found.</p>'}
|
|
93
96
|
${planHtml}
|
package/src/report-text.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SEVERITIES, countBySeverity } from './analyze.js';
|
|
2
2
|
import { SITE_URL } from './config.js';
|
|
3
|
+
import { startHere } from './plan.js';
|
|
3
4
|
|
|
4
5
|
export function textReport(result, { color = true, verbose = false } = {}) {
|
|
5
6
|
const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
@@ -64,7 +65,12 @@ export function textReport(result, { color = true, verbose = false } = {}) {
|
|
|
64
65
|
|
|
65
66
|
if (n.critical + n.high > 0) {
|
|
66
67
|
out.push('');
|
|
67
|
-
out.push(
|
|
68
|
+
out.push(c.bold('Start here'));
|
|
69
|
+
const plain = (t) => t.replace(/\*\*/g, '').replace(/`/g, '');
|
|
70
|
+
const cut = (t) => (t.length > 150 ? `${t.slice(0, 147).replace(/\s+\S*$/, '')}…` : t);
|
|
71
|
+
startHere(result).forEach((s, i) => out.push(` ${i + 1}. ${cut(plain(s.step))}`));
|
|
72
|
+
out.push('');
|
|
73
|
+
out.push(`Next: ${c.bold('npx nativekeel plan')} writes the full step-by-step plan.`);
|
|
68
74
|
out.push(c.gray(`No time to do it yourself? Fixed-price upgrades: ${SITE_URL}/#services`));
|
|
69
75
|
} else if (!result.findings.length) {
|
|
70
76
|
out.push(c.green('Nothing to fix. This app is in good shape.'));
|
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)
|
|
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").
|
|
@@ -158,6 +158,7 @@ export function stabilityChecks(project) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
for (const f of iosUsageDescriptions(project)) add(f);
|
|
161
162
|
return findings;
|
|
162
163
|
}
|
|
163
164
|
|
|
@@ -184,3 +185,64 @@ function listPackages(nm) {
|
|
|
184
185
|
}
|
|
185
186
|
return out;
|
|
186
187
|
}
|
|
188
|
+
|
|
189
|
+
// iOS terminates an app that touches a protected resource without the matching usage
|
|
190
|
+
// description in Info.plist, and App Review rejects it. Expo managed apps get these from config
|
|
191
|
+
// plugins, so only projects with their own ios/ folder are checked.
|
|
192
|
+
const USAGE_KEYS = [
|
|
193
|
+
{ key: 'NSCameraUsageDescription', what: 'the camera', pkgs: ['react-native-vision-camera', 'react-native-camera', 'expo-camera', 'react-native-camera-kit', 'react-native-qrcode-scanner'] },
|
|
194
|
+
{ key: 'NSPhotoLibraryUsageDescription', what: 'the photo library', pkgs: ['@react-native-camera-roll/camera-roll', '@react-native-community/cameraroll', 'expo-media-library', 'react-native-image-crop-picker'] },
|
|
195
|
+
{ key: 'NSLocationWhenInUseUsageDescription', what: 'location', pkgs: ['@react-native-community/geolocation', 'react-native-geolocation-service', 'expo-location', 'react-native-background-geolocation', '@mauron85/react-native-background-geolocation'] },
|
|
196
|
+
{ key: 'NSMicrophoneUsageDescription', what: 'the microphone', pkgs: ['@react-native-voice/voice', 'react-native-audio-recorder-player', 'expo-audio', 'react-native-audio-record'] },
|
|
197
|
+
{ key: 'NSContactsUsageDescription', what: 'contacts', pkgs: ['react-native-contacts', 'expo-contacts'] },
|
|
198
|
+
{ key: 'NSCalendarsUsageDescription', what: 'calendars', pkgs: ['react-native-calendar-events', 'expo-calendar'] },
|
|
199
|
+
{ key: 'NSBluetoothAlwaysUsageDescription', what: 'Bluetooth', pkgs: ['react-native-ble-plx', 'react-native-ble-manager'] },
|
|
200
|
+
{ key: 'NSFaceIDUsageDescription', what: 'Face ID', pkgs: ['react-native-biometrics', '@sbaiahmed1/react-native-biometrics', 'expo-local-authentication', 'react-native-touch-id'] },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
export function iosUsageDescriptions(project) {
|
|
204
|
+
const iosDir = path.join(project.root, 'ios');
|
|
205
|
+
if (!fs.existsSync(iosDir) || project.managed) return [];
|
|
206
|
+
const plists = [];
|
|
207
|
+
const walk = (dir, depth) => {
|
|
208
|
+
let entries = [];
|
|
209
|
+
try {
|
|
210
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
211
|
+
} catch {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const e of entries) {
|
|
215
|
+
if (e.isDirectory() && depth < 2 && !['Pods', 'build', 'DerivedData'].includes(e.name) && !/Tests?$|Extension$|Widget/.test(e.name)) walk(path.join(dir, e.name), depth + 1);
|
|
216
|
+
else if (e.name === 'Info.plist') plists.push(path.join(dir, e.name));
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
walk(iosDir, 0);
|
|
220
|
+
if (!plists.length) return [];
|
|
221
|
+
const text = plists.map(readText).join('\n');
|
|
222
|
+
// Expo projects can add the keys at prebuild from app.json/app.config (infoPlist or a config
|
|
223
|
+
// plugin of the same package): count those too.
|
|
224
|
+
const expoConfig = project.expoVersion ? ['app.json', 'app.config.js', 'app.config.ts'].map((f) => readText(path.join(project.root, f)) || '').join('\n') : '';
|
|
225
|
+
const deps = { ...project.deps };
|
|
226
|
+
const missing = [];
|
|
227
|
+
for (const u of USAGE_KEYS) {
|
|
228
|
+
// In Expo projects, prebuild applies the config plugins of installed expo-* packages on its
|
|
229
|
+
// own, and they add default usage strings.
|
|
230
|
+
const used = u.pkgs.filter((p) => deps[p] && !(project.expoVersion && p.startsWith('expo-')));
|
|
231
|
+
if (!used.length) continue;
|
|
232
|
+
const keys = u.key === 'NSPhotoLibraryUsageDescription' ? [u.key, 'NSPhotoLibraryAddUsageDescription'] : [u.key];
|
|
233
|
+
const inPlist = keys.some((k) => text.includes(`<key>${k}</key>`));
|
|
234
|
+
const inExpo = keys.some((k) => expoConfig.includes(k)) || used.some((p) => expoConfig.includes(`"${p}"`) || expoConfig.includes(`'${p}'`));
|
|
235
|
+
if (!inPlist && !inExpo) missing.push({ ...u, used });
|
|
236
|
+
}
|
|
237
|
+
if (!missing.length) return [];
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
id: 'crash-ios-usage-description',
|
|
241
|
+
severity: 'high',
|
|
242
|
+
area: 'crash',
|
|
243
|
+
title: `Info.plist is missing ${missing.length === 1 ? 'a usage description' : `${missing.length} usage descriptions`} (${missing.map((m) => m.key).join(', ')})`,
|
|
244
|
+
detail: `${missing.map((m) => `${m.used[0]} uses ${m.what}`).join('; ')}. iOS terminates the app the moment it asks for access without the matching key, and App Review rejects the build. Add each key with a sentence that says why the app needs it.`,
|
|
245
|
+
fix: { kind: 'crash', step: `Add ${missing.map((m) => `\`${m.key}\``).join(', ')} to the app's Info.plist, each with a user-facing reason.` },
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
}
|