nativekeel 0.1.12 → 0.1.14
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 +5 -3
- package/package.json +1 -1
- package/src/analyze.js +5 -2
- package/src/performance.js +163 -0
- package/src/plan.js +10 -0
- package/src/security.js +117 -1
- package/src/stability.js +83 -0
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.12:** hacking risks mapped to OWASP MASVS: open Firebase rules, TLS certificate checks turned off, hardcoded keys and weak crypto, tokens in plain storage, with a security overview in every report. **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)
|
|
9
|
+
**What's new in 0.1.14:** secrets in Expo config (`extra`, `EXPO_PUBLIC_`, `eas.json`) and imports of APIs removed from React Native core. **0.1.13:** performance traps (nested lists, JS-thread animations, release console logs, heavy imports, oversized images) and reverse-engineering exposure (source maps in the app, Hermes/R8 off). **0.1.12:** hacking risks mapped to OWASP MASVS: open Firebase rules, TLS certificate checks turned off, hardcoded keys and weak crypto, tokens in plain storage, with a security overview in every report. **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
|
|
|
@@ -15,12 +15,14 @@ It answers the questions that decide whether your app can still ship:
|
|
|
15
15
|
- **Which packages block the upgrade?** Native modules without New Architecture support, unmaintained or with no release in years, with known alternatives. Outdated "unmaintained" flags are cross-checked against the latest npm release.
|
|
16
16
|
- **What can you simply delete?** Packages that are installed but never imported, referenced natively or required by another package. Every one you remove is one you never upgrade.
|
|
17
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.
|
|
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.
|
|
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, and secrets in Expo `extra` or `eas.json`. 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
21
|
- **Can someone break in?** Grouped by OWASP MASVS: Firebase Realtime Database/Firestore/Storage rules open to anyone (or in test mode), TLS certificate checks turned off in native code, hardcoded encryption keys, MD5/SHA-1 on passwords, ECB mode, `Math.random` for nonces and salts. These are static checks: they catch the common, detectable mistakes, not everything a penetration test of the running app would.
|
|
22
22
|
- **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, auth tokens in unencrypted storage.
|
|
23
|
-
- **
|
|
23
|
+
- **Is it slow or heavy?** A FlatList inside a ScrollView (no virtualization), animations with `useNativeDriver: false`, `console.log` left in release builds, whole-library `lodash`/`moment` imports, images over 500 KB that the app requires. Static signs to measure on a device, not a profiler.
|
|
24
|
+
- **Can it be taken apart?** Source maps packaged into the app, Hermes or R8 off in release (OWASP MASVS-RESILIENCE). Obfuscation only slows an attacker down; keep secrets and checks on the server.
|
|
25
|
+
- **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, imports of APIs removed from React Native core (`AsyncStorage`, `Picker`, `ViewPropTypes`, …).
|
|
24
26
|
- **Leftovers from old templates?** Flipper, JavaScriptCore instead of Hermes.
|
|
25
27
|
|
|
26
28
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nativekeel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
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
|
@@ -5,7 +5,8 @@ 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 {
|
|
8
|
+
import { performanceChecks } from './performance.js';
|
|
9
|
+
import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, reverseEngineering, expoConfigSecrets, masvsOf, MASVS_GROUPS } from './security.js';
|
|
9
10
|
import { lockedVersions } from './lockfile.js';
|
|
10
11
|
import { findUnused, filesImportingWith } from './usage.js';
|
|
11
12
|
import { matchKnownIssues, compareVersions } from './known-issues.js';
|
|
@@ -403,9 +404,11 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
403
404
|
// 7. Secrets
|
|
404
405
|
// Setups that build and then crash at runtime.
|
|
405
406
|
for (const f of stabilityChecks(project)) add(f);
|
|
407
|
+
// Static signs of performance problems.
|
|
408
|
+
for (const f of performanceChecks(project)) add(f);
|
|
406
409
|
|
|
407
410
|
// Security beyond leaked keys.
|
|
408
|
-
for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root), ...exportedComponents(project.root), ...plainHttpCalls(project.root), ...cloudRules(project.root, project.deps, now), ...insecureTls(project.root), ...weakCrypto(project.root), ...tokenStorage(project.root)]) add(f);
|
|
411
|
+
for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root), ...exportedComponents(project.root), ...plainHttpCalls(project.root), ...cloudRules(project.root, project.deps, now), ...insecureTls(project.root), ...weakCrypto(project.root), ...tokenStorage(project.root), ...reverseEngineering(project.root, project), ...expoConfigSecrets(project.root)]) add(f);
|
|
409
412
|
if (!forcedOffline) {
|
|
410
413
|
// Exact versions only (installed, else the lockfile): a range would be a guess.
|
|
411
414
|
const locked = lockedVersions(project.root, project.deps);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Performance problems you can see in the code: things that make lists stutter, animations run
|
|
5
|
+
// on the JavaScript thread, or the app download heavier than it needs to be. Runtime profiling
|
|
6
|
+
// (frame drops, start-up time) needs a device; these are the static signs.
|
|
7
|
+
|
|
8
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'Pods', 'build', '.gradle', 'DerivedData', '.expo', 'dist', 'coverage', 'vendor', '__tests__', '__mocks__', 'e2e', 'android', 'ios']);
|
|
9
|
+
const JS_EXT = /\.(?:[cm]?[jt]sx?)$/;
|
|
10
|
+
|
|
11
|
+
function* walk(dir, test) {
|
|
12
|
+
let entries;
|
|
13
|
+
try {
|
|
14
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
15
|
+
} catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
if (e.isDirectory()) {
|
|
20
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) yield* walk(path.join(dir, e.name), test);
|
|
21
|
+
} else if (test.test(e.name) && !/\.(test|spec)[._]/.test(e.name)) {
|
|
22
|
+
yield path.join(dir, e.name);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const read = (file) => {
|
|
28
|
+
try {
|
|
29
|
+
return fs.statSync(file).size > 512 * 1024 ? '' : fs.readFileSync(file, 'utf8');
|
|
30
|
+
} catch {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The props text of the JSX tag starting at `start` (up to its closing `>` or `/>`, skipping
|
|
36
|
+
// `>` inside {...} expressions such as arrow functions).
|
|
37
|
+
function tagProps(text, start) {
|
|
38
|
+
let depth = 0;
|
|
39
|
+
for (let i = start + 1; i < text.length && i < start + 4000; i++) {
|
|
40
|
+
const c = text[i];
|
|
41
|
+
if (c === '{') depth++;
|
|
42
|
+
else if (c === '}') depth--;
|
|
43
|
+
else if (c === '>' && depth === 0) return { props: text.slice(start, i), end: i + 1 };
|
|
44
|
+
}
|
|
45
|
+
return { props: text.slice(start, start + 4000), end: start + 4000 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function performanceChecks(project) {
|
|
49
|
+
const root = project.root;
|
|
50
|
+
const findings = [];
|
|
51
|
+
const nested = [];
|
|
52
|
+
let jsDriver = 0;
|
|
53
|
+
let jsDriverAt = null;
|
|
54
|
+
let logs = 0;
|
|
55
|
+
const heavyImports = new Map();
|
|
56
|
+
for (const file of walk(root, JS_EXT)) {
|
|
57
|
+
const text = read(file);
|
|
58
|
+
if (!text) continue;
|
|
59
|
+
const rel = path.relative(root, file);
|
|
60
|
+
const code = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
|
61
|
+
|
|
62
|
+
// A vertical FlatList/SectionList inside a vertical ScrollView renders every item at once
|
|
63
|
+
// (React Native warns "VirtualizedLists should never be nested").
|
|
64
|
+
// JSX tags only: not TypeScript generics like useRef<ScrollView>(null).
|
|
65
|
+
for (const m of code.matchAll(/(?<![\w.$])<ScrollView\b/g)) {
|
|
66
|
+
const open = tagProps(code, m.index);
|
|
67
|
+
if (/\bhorizontal\b/.test(open.props)) continue;
|
|
68
|
+
const close = code.indexOf('</ScrollView>', open.end);
|
|
69
|
+
if (close < 0) continue;
|
|
70
|
+
const body = code.slice(open.end, close);
|
|
71
|
+
const im = body.match(/(?<![\w.$])<(FlatList|SectionList|FlashList)\b/);
|
|
72
|
+
if (!im) continue;
|
|
73
|
+
const inner = tagProps(body, im.index);
|
|
74
|
+
if (!/\bhorizontal\b/.test(inner.props) && !/scrollEnabled\s*=\s*\{\s*false\s*\}/.test(inner.props)) {
|
|
75
|
+
nested.push(`${rel}:${code.slice(0, m.index).split('\n').length}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Animations driven from JavaScript: every frame crosses to the JS thread.
|
|
79
|
+
for (const m of code.matchAll(/useNativeDriver\s*:\s*false/g)) {
|
|
80
|
+
jsDriver++;
|
|
81
|
+
jsDriverAt = jsDriverAt || `${rel}:${code.slice(0, m.index).split('\n').length}`;
|
|
82
|
+
}
|
|
83
|
+
logs += (code.match(/\bconsole\.(?:log|debug|info)\s*\(/g) || []).length;
|
|
84
|
+
// Whole-library imports of packages with per-function or lighter alternatives.
|
|
85
|
+
if (/from\s+['"]lodash['"]|require\(\s*['"]lodash['"]\s*\)/.test(code)) heavyImports.set('lodash', heavyImports.get('lodash') || rel);
|
|
86
|
+
if (/from\s+['"]moment['"]|require\(\s*['"]moment['"]\s*\)/.test(code)) heavyImports.set('moment', heavyImports.get('moment') || rel);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (nested.length) {
|
|
90
|
+
findings.push({
|
|
91
|
+
id: 'perf-nested-lists',
|
|
92
|
+
severity: 'medium',
|
|
93
|
+
area: 'performance',
|
|
94
|
+
title: `A list inside a ScrollView renders every item at once (${nested[0]}${nested.length > 1 ? ` and ${nested.length - 1} more` : ''})`,
|
|
95
|
+
detail: 'A vertical FlatList/SectionList inside a vertical ScrollView loses virtualization: all rows mount up front, which costs memory and makes long lists stutter or crash on low-end Android. Use the list as the scroll container and move the other content into ListHeaderComponent/ListFooterComponent.',
|
|
96
|
+
fix: { kind: 'performance', step: `In \`${nested[0]}\`, make the FlatList the scroll container (ListHeaderComponent/ListFooterComponent) instead of nesting it in a ScrollView.` },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (jsDriver >= 3) {
|
|
100
|
+
findings.push({
|
|
101
|
+
id: 'perf-js-driven-animations',
|
|
102
|
+
severity: 'low',
|
|
103
|
+
area: 'performance',
|
|
104
|
+
title: `${jsDriver} animations run on the JavaScript thread (useNativeDriver: false, e.g. ${jsDriverAt})`,
|
|
105
|
+
detail: 'Each frame of these animations waits for JavaScript, so they drop frames whenever the app is busy. Transform and opacity animations can use useNativeDriver: true; layout animations can move to Reanimated.',
|
|
106
|
+
fix: { kind: 'performance', step: 'Switch transform/opacity animations to `useNativeDriver: true` (or Reanimated for layout properties).' },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const babel = ['babel.config.js', '.babelrc', 'babel.config.json'].map((f) => read(path.join(root, f))).join('\n');
|
|
110
|
+
if (logs >= 30 && !/transform-remove-console|remove-console/.test(babel)) {
|
|
111
|
+
findings.push({
|
|
112
|
+
id: 'perf-console-in-release',
|
|
113
|
+
severity: 'low',
|
|
114
|
+
area: 'performance',
|
|
115
|
+
title: `${logs} console.log calls ship in the release build`,
|
|
116
|
+
detail: 'They run in production, slow the JavaScript thread in hot paths, and can write user data to the device log. Strip them from release builds with babel-plugin-transform-remove-console (keep console.error/warn).',
|
|
117
|
+
fix: { kind: 'performance', step: "Add `['transform-remove-console', { exclude: ['error', 'warn'] }]` to the production `env` of your Babel config." },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (heavyImports.size) {
|
|
121
|
+
const advice = { lodash: 'import single functions (lodash/debounce) or use lodash-es with tree shaking', moment: 'use dayjs or date-fns (moment is in maintenance mode and pulls in all locales)' };
|
|
122
|
+
findings.push({
|
|
123
|
+
id: 'perf-heavy-imports',
|
|
124
|
+
severity: 'low',
|
|
125
|
+
area: 'performance',
|
|
126
|
+
title: `Whole-library imports of ${[...heavyImports.keys()].join(' and ')}`,
|
|
127
|
+
detail: `${[...heavyImports].map(([k, at]) => `${k} (${at}): ${advice[k]}`).join('; ')}. They add hundreds of KB to the bundle the app parses at start-up.`,
|
|
128
|
+
fix: { kind: 'performance', step: `Replace whole-library imports: ${[...heavyImports.keys()].map((k) => advice[k]).join('; ')}.` },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Oversized images bundled with the app (require()d from code): download size and decode
|
|
133
|
+
// memory. Screenshots and docs in the repo are not part of the app, so only required files count.
|
|
134
|
+
const big = [];
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
for (const file of walk(root, JS_EXT)) {
|
|
137
|
+
const text = read(file);
|
|
138
|
+
for (const m of text.matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+\.(?:png|jpe?g|gif|webp))['"]\s*\)|import\s+\w+\s+from\s+['"](\.{1,2}\/[^'"]+\.(?:png|jpe?g|gif|webp))['"]/gi)) {
|
|
139
|
+
const img = path.resolve(path.dirname(file), m[1] || m[2]);
|
|
140
|
+
if (seen.has(img)) continue;
|
|
141
|
+
seen.add(img);
|
|
142
|
+
try {
|
|
143
|
+
const size = fs.statSync(img).size;
|
|
144
|
+
if (size > 500 * 1024) big.push({ rel: path.relative(root, img), size });
|
|
145
|
+
} catch {
|
|
146
|
+
// missing or @2x variant only
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (big.length) {
|
|
151
|
+
big.sort((a, b) => b.size - a.size);
|
|
152
|
+
const total = big.reduce((s, b) => s + b.size, 0);
|
|
153
|
+
findings.push({
|
|
154
|
+
id: 'perf-large-images',
|
|
155
|
+
severity: big.length >= 5 || total > 10 * 1024 * 1024 ? 'medium' : 'low',
|
|
156
|
+
area: 'performance',
|
|
157
|
+
title: `${big.length} image${big.length === 1 ? '' : 's'} over 500 KB in the app (${(total / 1024 / 1024).toFixed(1)} MB)`,
|
|
158
|
+
detail: `${big.slice(0, 3).map((b) => `${b.rel} (${Math.round(b.size / 1024)} KB)`).join(', ')}${big.length > 3 ? ', …' : ''}. They grow the download and take memory when decoded. Resize to the size shown on screen (2x/3x) and use WebP.`,
|
|
159
|
+
fix: { kind: 'performance', step: `Resize and convert the largest bundled images (${big.slice(0, 2).map((b) => `\`${b.rel}\``).join(', ')}) to WebP at the size shown on screen.` },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return findings;
|
|
163
|
+
}
|
package/src/plan.js
CHANGED
|
@@ -66,6 +66,7 @@ const WEIGHTS = {
|
|
|
66
66
|
'folly-flags': () => 0.25,
|
|
67
67
|
'default-react-host': () => 0.25,
|
|
68
68
|
crash: () => 1,
|
|
69
|
+
performance: () => 0.5,
|
|
69
70
|
security: () => 1,
|
|
70
71
|
'vuln-dep': (f) => (f.severity === 'critical' || f.severity === 'high' ? 1 : 0.25),
|
|
71
72
|
};
|
|
@@ -526,6 +527,15 @@ export function buildPlan(result) {
|
|
|
526
527
|
});
|
|
527
528
|
}
|
|
528
529
|
|
|
530
|
+
const perf = byKind('performance');
|
|
531
|
+
if (perf.length) {
|
|
532
|
+
phases.push({
|
|
533
|
+
title: 'Speed up the app',
|
|
534
|
+
why: 'Static signs of jank and weight. Measure on a low-end Android device before and after: these are the usual suspects, not proof.',
|
|
535
|
+
steps: perf.map((p) => p.step),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
529
539
|
const jsBumps = byKind('bump-dep').filter((d) => !d.native && !movesWithExpo(d.name));
|
|
530
540
|
if (jsBumps.length) {
|
|
531
541
|
phases.push({
|
package/src/security.js
CHANGED
|
@@ -584,14 +584,130 @@ export const MASVS_GROUPS = {
|
|
|
584
584
|
NETWORK: 'Network traffic',
|
|
585
585
|
PLATFORM: 'WebViews and platform interaction',
|
|
586
586
|
CODE: 'Dependencies and build',
|
|
587
|
+
RESILIENCE: 'Reverse engineering',
|
|
587
588
|
};
|
|
588
589
|
|
|
589
590
|
export function masvsOf(id) {
|
|
590
|
-
if (/^(secret:|env-bundled|crypto-)/.test(id)) return 'CRYPTO';
|
|
591
|
+
if (/^(secret:|env-bundled|crypto-|expo-secret)/.test(id)) return 'CRYPTO';
|
|
591
592
|
if (/^(password-leak|token-unencrypted|android-allow-backup)/.test(id)) return 'STORAGE';
|
|
592
593
|
if (/^(insecure-tls|user-certificates|plain-http|ios-ats|android-cleartext)/.test(id)) return 'NETWORK';
|
|
593
594
|
if (/^(webview-|android-exported)/.test(id)) return 'PLATFORM';
|
|
594
595
|
if (/^firebase-/.test(id)) return 'AUTH';
|
|
595
596
|
if (/^(vuln:|signing-|android-debuggable)/.test(id)) return 'CODE';
|
|
597
|
+
if (/^(sourcemap-in-app|easy-reverse-engineering)/.test(id)) return 'RESILIENCE';
|
|
596
598
|
return null;
|
|
597
599
|
}
|
|
600
|
+
|
|
601
|
+
// How easily the shipped app reads back as source (MASVS-RESILIENCE). React Native JavaScript is
|
|
602
|
+
// recoverable from any APK/IPA; Hermes bytecode and R8 only slow that down. The real defence is
|
|
603
|
+
// keeping secrets and trust decisions off the device, so this is advice, not an alarm.
|
|
604
|
+
export function reverseEngineering(root, project) {
|
|
605
|
+
const findings = [];
|
|
606
|
+
// Source maps packaged into the app give back the original code, names and comments.
|
|
607
|
+
const mapDirs = [path.join(root, 'android', 'app', 'src', 'main', 'assets'), path.join(root, 'ios')];
|
|
608
|
+
const maps = [];
|
|
609
|
+
const scan = (dir, depth) => {
|
|
610
|
+
let entries = [];
|
|
611
|
+
try {
|
|
612
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
613
|
+
} catch {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
for (const e of entries) {
|
|
617
|
+
if (e.isDirectory() && depth < 3 && !['Pods', 'build', 'DerivedData'].includes(e.name)) scan(path.join(dir, e.name), depth + 1);
|
|
618
|
+
else if (/\.(?:bundle|jsbundle|hbc)\.map$|^index\.android\.bundle\.map$|^main\.jsbundle\.map$/.test(e.name)) maps.push(path.relative(root, path.join(dir, e.name)));
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
for (const d of mapDirs) scan(d, 0);
|
|
622
|
+
if (maps.length) {
|
|
623
|
+
findings.push({
|
|
624
|
+
id: 'sourcemap-in-app',
|
|
625
|
+
severity: 'high',
|
|
626
|
+
area: 'security',
|
|
627
|
+
title: `A source map is packaged into the app (${maps[0]})`,
|
|
628
|
+
detail: 'Anyone who unzips the APK/IPA gets your original JavaScript with names and comments. Upload source maps to your crash reporter instead, and keep them out of the app folders. (OWASP MASVS-RESILIENCE)',
|
|
629
|
+
fix: { kind: 'security', step: `Remove \`${maps[0]}\` from the app sources (and from git), and upload source maps to the crash reporter instead.` },
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const gradle = read(path.join(root, 'android', 'app', 'build.gradle')) || read(path.join(root, 'android', 'app', 'build.gradle.kts')) || '';
|
|
634
|
+
const props = read(path.join(root, 'android', 'gradle.properties')) || '';
|
|
635
|
+
const r8Off = gradle && (/enableProguardInReleaseBuilds\s*=\s*false/.test(gradle) || /release\s*\{[^}]*minifyEnabled\s*\(?\s*false/.test(gradle));
|
|
636
|
+
const hermesOff = /^\s*hermesEnabled\s*=\s*false/m.test(props) || /hermes_enabled\s*=>\s*false/.test(read(path.join(root, 'ios', 'Podfile')) || '');
|
|
637
|
+
const factors = [hermesOff && 'Hermes is off, so the JavaScript ships as readable text', r8Off && 'R8/ProGuard is off, so Java/Kotlin class and method names stay readable'].filter(Boolean);
|
|
638
|
+
if (factors.length && !project.managed) {
|
|
639
|
+
findings.push({
|
|
640
|
+
id: 'easy-reverse-engineering',
|
|
641
|
+
severity: 'low',
|
|
642
|
+
area: 'security',
|
|
643
|
+
title: 'The release build is easy to read back',
|
|
644
|
+
detail: `${factors.join('; ')}. Any React Native app can be unpacked, and obfuscation only slows that down, so never rely on it: keep secrets and checks that matter (prices, entitlements, admin flags) on the server. Turning these on is still cheap. (OWASP MASVS-RESILIENCE)`,
|
|
645
|
+
fix: { kind: 'security', step: `${hermesOff ? 'Turn on Hermes. ' : ''}${r8Off ? 'Set `enableProguardInReleaseBuilds = true` in `android/app/build.gradle` and test a release build (add keep rules for libraries that need them). ' : ''}Keep secrets and trust decisions on the server.` },
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return findings;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Secrets in Expo config: app.json/app.config `extra` ships inside the app (Constants.expoConfig),
|
|
652
|
+
// EXPO_PUBLIC_ variables are compiled into the bundle, and eas.json build env is committed.
|
|
653
|
+
// Names alone decide; many SDK "API keys" are public by design, so those are left alone.
|
|
654
|
+
const SECRET_NAME = /(?:secret|private|password|passwd|auth[_-]?token|access[_-]?token|refresh[_-]?token|access[_-]?key|service[_-]?role|admin[_-]?key|token$)/i;
|
|
655
|
+
const maskValue = (v) => (v.length <= 8 ? '****' : `${v.slice(0, 4)}…${v.slice(-2)}`);
|
|
656
|
+
|
|
657
|
+
export function expoConfigSecrets(root) {
|
|
658
|
+
const findings = [];
|
|
659
|
+
const shipped = [];
|
|
660
|
+
const committed = [];
|
|
661
|
+
const looksReal = (v) => typeof v === 'string' && v.length >= 12 && !/^\$\{|^process\.env|example|placeholder|your[_-]|xxx|changeme|<.*>/i.test(v);
|
|
662
|
+
|
|
663
|
+
let app = null;
|
|
664
|
+
try {
|
|
665
|
+
app = JSON.parse(read(path.join(root, 'app.json')) || 'null');
|
|
666
|
+
} catch {
|
|
667
|
+
// not JSON
|
|
668
|
+
}
|
|
669
|
+
const extra = (app && (app.expo || app).extra) || {};
|
|
670
|
+
for (const [k, v] of Object.entries(extra)) if (SECRET_NAME.test(k) && looksReal(v)) shipped.push({ where: `app.json extra.${k}`, value: v });
|
|
671
|
+
for (const f of ['app.config.js', 'app.config.ts']) {
|
|
672
|
+
const text = read(path.join(root, f));
|
|
673
|
+
const block = text && text.match(/\bextra\s*:\s*\{([\s\S]*?)\n\s*\}/);
|
|
674
|
+
if (!block) continue;
|
|
675
|
+
for (const m of block[1].matchAll(/([A-Za-z_][\w]*)\s*:\s*['"`]([^'"`\n]{12,})['"`]/g)) {
|
|
676
|
+
if (SECRET_NAME.test(m[1]) && looksReal(m[2])) shipped.push({ where: `${f} extra.${m[1]}`, value: m[2] });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
let eas = null;
|
|
680
|
+
try {
|
|
681
|
+
eas = JSON.parse(read(path.join(root, 'eas.json')) || 'null');
|
|
682
|
+
} catch {
|
|
683
|
+
// not JSON
|
|
684
|
+
}
|
|
685
|
+
for (const [profile, cfg] of Object.entries((eas && eas.build) || {})) {
|
|
686
|
+
for (const [k, v] of Object.entries((cfg && cfg.env) || {})) {
|
|
687
|
+
if (!SECRET_NAME.test(k) || !looksReal(v)) continue;
|
|
688
|
+
if (k.startsWith('EXPO_PUBLIC_')) shipped.push({ where: `eas.json build.${profile}.env.${k}`, value: v });
|
|
689
|
+
else committed.push({ where: `eas.json build.${profile}.env.${k}`, value: v });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (shipped.length) {
|
|
693
|
+
findings.push({
|
|
694
|
+
id: 'expo-secret-shipped',
|
|
695
|
+
severity: 'high',
|
|
696
|
+
area: 'security',
|
|
697
|
+
title: `Secret-looking value${shipped.length === 1 ? '' : 's'} shipped inside the app via Expo config (${shipped[0].where})`,
|
|
698
|
+
detail: `${shipped.map((s) => `${s.where} = ${maskValue(s.value)}`).slice(0, 3).join('; ')}. Expo \`extra\` is readable from the app manifest and EXPO_PUBLIC_ variables are compiled into the bundle, so anyone with the app can read them. Revoke them and move the calls that need them to a server. (OWASP MASVS-CRYPTO)`,
|
|
699
|
+
fix: { kind: 'secret', label: 'Secret in Expo config', file: shipped[0].where.split(' ')[0], line: 1, bundled: true },
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
if (committed.length) {
|
|
703
|
+
findings.push({
|
|
704
|
+
id: 'expo-secret-committed',
|
|
705
|
+
severity: 'high',
|
|
706
|
+
area: 'security',
|
|
707
|
+
title: `Secret committed in eas.json (${committed[0].where})`,
|
|
708
|
+
detail: `${committed.map((s) => `${s.where} = ${maskValue(s.value)}`).slice(0, 3).join('; ')}. Everyone with repository access can use it (a Sentry auth token, for example, can read and change your Sentry organisation). Rotate it and store it as an EAS secret (\`eas env:create\`) instead. (OWASP MASVS-CRYPTO)`,
|
|
709
|
+
fix: { kind: 'secret', label: 'Secret in eas.json', file: 'eas.json', line: 1, bundled: false },
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
return findings;
|
|
713
|
+
}
|
package/src/stability.js
CHANGED
|
@@ -159,6 +159,7 @@ export function stabilityChecks(project) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
for (const f of iosUsageDescriptions(project)) add(f);
|
|
162
|
+
for (const f of removedCoreImports(project)) add(f);
|
|
162
163
|
return findings;
|
|
163
164
|
}
|
|
164
165
|
|
|
@@ -246,3 +247,85 @@ export function iosUsageDescriptions(project) {
|
|
|
246
247
|
},
|
|
247
248
|
];
|
|
248
249
|
}
|
|
250
|
+
|
|
251
|
+
// Components and APIs that left React Native core. Importing them from 'react-native' throws
|
|
252
|
+
// ("X has been removed from react-native core", checked in RN's index.js for 0.72-0.76) or, from
|
|
253
|
+
// 0.74 for the PropTypes and later for the rest, is simply undefined: a crash on first use.
|
|
254
|
+
const REMOVED_FROM_CORE = {
|
|
255
|
+
AsyncStorage: '@react-native-async-storage/async-storage',
|
|
256
|
+
NetInfo: '@react-native-community/netinfo',
|
|
257
|
+
WebView: 'react-native-webview',
|
|
258
|
+
Slider: '@react-native-community/slider',
|
|
259
|
+
ListView: 'FlatList or SectionList (core)',
|
|
260
|
+
SwipeableListView: 'FlatList with a swipeable row library',
|
|
261
|
+
CameraRoll: '@react-native-camera-roll/camera-roll',
|
|
262
|
+
ImageEditor: '@react-native-community/image-editor',
|
|
263
|
+
ImageStore: 'expo-file-system or react-native-blob-util',
|
|
264
|
+
TimePickerAndroid: '@react-native-community/datetimepicker',
|
|
265
|
+
DatePickerAndroid: '@react-native-community/datetimepicker',
|
|
266
|
+
DatePickerIOS: '@react-native-community/datetimepicker',
|
|
267
|
+
ToolbarAndroid: '@react-native-community/toolbar-android',
|
|
268
|
+
ViewPagerAndroid: 'react-native-pager-view',
|
|
269
|
+
CheckBox: '@react-native-community/checkbox',
|
|
270
|
+
SegmentedControlIOS: '@react-native-segmented-control/segmented-control',
|
|
271
|
+
StatusBarIOS: 'StatusBar (core)',
|
|
272
|
+
Picker: '@react-native-picker/picker',
|
|
273
|
+
PickerIOS: '@react-native-picker/picker',
|
|
274
|
+
MaskedViewIOS: '@react-native-masked-view/masked-view',
|
|
275
|
+
ImagePickerIOS: 'react-native-image-picker',
|
|
276
|
+
ProgressViewIOS: '@react-native-community/progress-view',
|
|
277
|
+
ART: 'react-native-svg',
|
|
278
|
+
};
|
|
279
|
+
const REMOVED_PROP_TYPES = ['ViewPropTypes', 'ColorPropType', 'EdgeInsetsPropType', 'PointPropType'];
|
|
280
|
+
|
|
281
|
+
export function removedCoreImports(project) {
|
|
282
|
+
const root = project.root;
|
|
283
|
+
const used = new Map(); // name -> first file:line
|
|
284
|
+
const files = [];
|
|
285
|
+
const walk = (dir) => {
|
|
286
|
+
let entries = [];
|
|
287
|
+
try {
|
|
288
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
for (const e of entries) {
|
|
293
|
+
if (e.isDirectory()) {
|
|
294
|
+
if (!['node_modules', '.git', 'android', 'ios', 'build', 'dist', '.expo', 'coverage', '__tests__', '__mocks__'].includes(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name));
|
|
295
|
+
} else if (/\.[cm]?[jt]sx?$/.test(e.name) && !/\.(test|spec)\./.test(e.name)) {
|
|
296
|
+
files.push(path.join(dir, e.name));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
walk(root);
|
|
301
|
+
for (const file of files) {
|
|
302
|
+
const text = readText(file) || '';
|
|
303
|
+
if (!text.includes("'react-native'") && !text.includes('"react-native"')) continue;
|
|
304
|
+
const blocks = [
|
|
305
|
+
...[...text.matchAll(/import\s*(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]react-native['"]/g)].map((m) => ({ names: m[1], index: m.index })),
|
|
306
|
+
...[...text.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(\s*['"]react-native['"]\s*\)/g)].map((m) => ({ names: m[1], index: m.index })),
|
|
307
|
+
];
|
|
308
|
+
for (const b of blocks) {
|
|
309
|
+
for (const raw of b.names.split(',')) {
|
|
310
|
+
const name = raw.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim();
|
|
311
|
+
if ((REMOVED_FROM_CORE[name] || REMOVED_PROP_TYPES.includes(name)) && !used.has(name)) {
|
|
312
|
+
used.set(name, `${path.relative(root, file)}:${text.slice(0, b.index).split('\n').length}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (!used.size) return [];
|
|
318
|
+
const minor = project.rnVersion ? Number(project.rnVersion.split('.')[1]) : null;
|
|
319
|
+
const crashesNow = [...used.keys()].some((n) => (REMOVED_PROP_TYPES.includes(n) ? minor >= 74 : minor >= 72));
|
|
320
|
+
const list = [...used].map(([n, at]) => `${n} (${at}) → ${REMOVED_FROM_CORE[n] || 'deprecated-react-native-prop-types'}`);
|
|
321
|
+
return [
|
|
322
|
+
{
|
|
323
|
+
id: 'crash-removed-core-imports',
|
|
324
|
+
severity: crashesNow ? 'high' : 'medium',
|
|
325
|
+
area: 'crash',
|
|
326
|
+
title: `${used.size} import${used.size === 1 ? '' : 's'} of APIs removed from React Native core${crashesNow ? '' : ' (they break when you upgrade)'}`,
|
|
327
|
+
detail: `${list.slice(0, 6).join('; ')}${list.length > 6 ? '; …' : ''}. Importing these from 'react-native' throws "has been removed from react-native core" or returns undefined on current versions, which crashes the screen that uses them. Install the replacement and change the import.`,
|
|
328
|
+
fix: { kind: 'crash', step: `Move ${[...used.keys()].map((n) => `\`${n}\``).join(', ')} off 'react-native' to their community packages (${[...new Set([...used.keys()].map((n) => REMOVED_FROM_CORE[n] || 'deprecated-react-native-prop-types'))].slice(0, 4).join(', ')}).` },
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
}
|