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 +6 -1
- package/package.json +9 -3
- package/src/analyze.js +25 -3
- package/src/lockfile.js +125 -0
- package/src/plan.js +43 -8
- package/src/registry.js +18 -2
- package/src/report-html.js +11 -2
- package/src/report-sarif.js +6 -1
- package/src/rules.js +15 -0
- package/src/security.js +259 -0
- package/src/stability.js +186 -0
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.
|
|
4
|
-
"description": "Health check for React Native apps:
|
|
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}`;
|
package/src/lockfile.js
ADDED
|
@@ -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: '
|
|
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
|
|
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,
|
|
405
|
-
//
|
|
406
|
-
const major = d.version ?
|
|
407
|
-
const same = major !== null && best &&
|
|
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
|
-
|
|
413
|
-
|
|
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
|
|
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.
|
package/src/report-html.js
CHANGED
|
@@ -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>
|
package/src/report-sarif.js
CHANGED
|
@@ -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) =>
|
package/src/security.js
ADDED
|
@@ -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
|
+
}
|
package/src/stability.js
ADDED
|
@@ -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
|
+
}
|