nativekeel 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Health check for React Native and Expo apps. One command, no install, no account
6
6
  npx nativekeel
7
7
  ```
8
8
 
9
- **What's new in 0.1.11:** the GitHub Action is fixed and tested on every change. **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.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
 
@@ -18,7 +18,10 @@ 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, Android components any app can start, API calls over plain HTTP.
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
+ - **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
+ - **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.
22
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.
23
26
  - **Leftovers from old templates?** Flipper, JavaScriptCore instead of Hermes.
24
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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 { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls } from './security.js';
8
+ import { performanceChecks } from './performance.js';
9
+ import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, reverseEngineering, 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,14 +404,20 @@ 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)]) 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)]) 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);
412
415
  const exact = {};
413
416
  for (const name of Object.keys(project.deps)) {
417
+ // Advisories describe npm registry packages. A dependency from git, a URL or a local
418
+ // path is a different package that only shares the name (e.g. a squatted name with a
419
+ // malware advisory), so it is not matched.
420
+ if (/:\/\/|^(github|gitlab|bitbucket|git|file|link|workspace|portal|patch):|^[\w.-]+\/[\w.-]+(#|$)/.test(String(project.deps[name]))) continue;
414
421
  const v = installedVersion(project.root, name) || locked[name];
415
422
  if (v && /^\d+\.\d+\.\d+/.test(v)) exact[name] = v;
416
423
  }
@@ -453,6 +460,14 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
453
460
  }
454
461
 
455
462
  reportIncomplete();
463
+ // OWASP MASVS group for every security finding, and a per-group count (zero means checked, clean).
464
+ const security = Object.fromEntries(Object.keys(MASVS_GROUPS).map((g) => [g, 0]));
465
+ for (const f of findings) {
466
+ const g = masvsOf(f.id);
467
+ if (!g) continue;
468
+ f.masvs = g;
469
+ if (f.severity !== 'info') security[g]++;
470
+ }
456
471
  findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
457
472
  return {
458
473
  tool: 'nativekeel',
@@ -467,6 +482,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
467
482
  android: project.android,
468
483
  },
469
484
  latest: { reactNative: rn && rn.latest, expo: expo && expo.latest },
485
+ security,
470
486
  offline,
471
487
  offlineRequested: forcedOffline,
472
488
  warnings,
@@ -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
  };
@@ -274,21 +275,25 @@ export function buildPlan(result) {
274
275
  });
275
276
  }
276
277
 
277
- const securitySteps = [];
278
- for (const m of byKind('manifest')) {
279
- securitySteps.push(m.attr === 'android:debuggable'
280
- ? 'Remove `android:debuggable="true"` from `android/app/src/main/AndroidManifest.xml`.'
281
- : 'Move `android:usesCleartextTraffic="true"` to `android/app/src/debug/AndroidManifest.xml`, or allow only specific hosts with a network security config.');
282
- }
283
- for (const a of byKind('ats')) {
284
- securitySteps.push(`In \`${a.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.`);
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.'}`);
278
+ // Security steps, most severe first (they feed "Start here").
279
+ const sevRank = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
280
+ const sec = [];
281
+ for (const f of result.findings) {
282
+ if (!f.fix) continue;
283
+ const sev = sevRank[f.severity] ?? 4;
284
+ if (f.fix.kind === 'manifest') {
285
+ sec.push({ sev, step: f.fix.attr === 'android:debuggable' ? 'Remove `android:debuggable="true"` from `android/app/src/main/AndroidManifest.xml`.' : 'Move `android:usesCleartextTraffic="true"` to `android/app/src/debug/AndroidManifest.xml`, or allow only specific hosts with a network security config.' });
286
+ } else if (f.fix.kind === 'ats') {
287
+ sec.push({ sev, step: `In \`${f.fix.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.` });
288
+ } else if (f.fix.kind === 'security') {
289
+ sec.push({ sev, step: f.fix.step });
290
+ } else if (f.fix.kind === 'vuln-dep' && (f.fix.severity === 'critical' || f.fix.severity === 'high')) {
291
+ const v = f.fix;
292
+ sec.push({ sev, step: `Update \`${v.name}\` ${v.from}${v.to ? ` to ${v.to} or later` : ''}: it has ${v.severity} known vulnerabilities. ${movesWithExpo(v.name) ? 'Expo pins it: the SDK upgrade below moves it.' : 'Check its changelog for breaking changes.'}` });
293
+ }
290
294
  }
291
- const minorVulns = byKind('vuln-dep').length - vulns.length;
295
+ const securitySteps = sec.sort((a, b) => a.sev - b.sev).map((x) => x.step);
296
+ const minorVulns = byKind('vuln-dep').filter((v) => v.severity !== 'critical' && v.severity !== 'high').length;
292
297
  if (minorVulns) securitySteps.push(`${minorVulns} more package${minorVulns === 1 ? ' has' : 's have'} moderate or low advisories: update them with the routine package updates below.`);
293
298
  if (securitySteps.length) {
294
299
  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 });
@@ -522,6 +527,15 @@ export function buildPlan(result) {
522
527
  });
523
528
  }
524
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
+
525
539
  const jsBumps = byKind('bump-dep').filter((d) => !d.native && !movesWithExpo(d.name));
526
540
  if (jsBumps.length) {
527
541
  phases.push({
@@ -1,6 +1,7 @@
1
1
  import { SEVERITIES, countBySeverity } from './analyze.js';
2
2
  import { SITE_URL } from './config.js';
3
3
  import { startHere } from './plan.js';
4
+ import { MASVS_GROUPS } from './security.js';
4
5
 
5
6
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
6
7
  // Turns `code` spans from finding text into <code>.
@@ -60,6 +61,7 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:32px 0 12px}h2 small{f
60
61
  .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}
61
62
  ul.list{list-style:none;padding:0;margin:0;display:grid;gap:8px}
62
63
  .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}
64
+ .secgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.sg{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column}.sg b{font-size:20px}.sg.ok b{color:var(--accent)}.sg.bad b{color:var(--critical)}.sg small{color:var(--muted)}@media (max-width:560px){.secgrid{grid-template-columns:1fr 1fr}}
63
65
  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}
64
66
  .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}
65
67
  .f p{margin:2px 0 0;color:var(--muted);font-size:14px}.f strong{overflow-wrap:anywhere}
@@ -91,6 +93,9 @@ ${p.android && p.android.targetSdk ? `<span>targetSdk <b>${p.android.targetSdk}<
91
93
  <span>${result.deps.length} dependencies (${result.deps.filter((d) => d.native).length} native)</span>
92
94
  </div>
93
95
  ${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>` : ''}
96
+ ${result.security ? `<section class="sec"><h2>Security <small>OWASP MASVS</small></h2><div class="secgrid">${Object.entries(MASVS_GROUPS)
97
+ .map(([g, label]) => `<div class="sg ${result.security[g] ? 'bad' : 'ok'}"><b>${result.security[g] ? result.security[g] : '✓'}</b><span>${esc(label)}</span><small>MASVS-${g}</small></div>`)
98
+ .join('')}</div><p class="muted">Static checks of code, config and dependencies. They find the common, detectable mistakes; they do not replace a penetration test of the running app and its backend.</p></section>` : ''}
94
99
  <h2>Findings</h2>
95
100
  ${result.findings.length ? `${rows ? `<ul class="list">${rows}</ul>` : ''}${bumpTable}` : '<p>No issues found.</p>'}
96
101
  ${planHtml}
@@ -62,6 +62,11 @@ export function textReport(result, { color = true, verbose = false } = {}) {
62
62
  }
63
63
  const n = countBySeverity(result.findings);
64
64
  out.push(`${c.bold('Summary')} ${c.red(`${n.critical} critical`)} · ${c.yellow(`${n.high} high`)} · ${n.medium} medium · ${n.low} low`);
65
+ if (result.security) {
66
+ const groups = Object.entries(result.security);
67
+ const withIssues = groups.filter(([, k]) => k > 0).map(([g, k]) => `${g.toLowerCase()} ${k}`);
68
+ out.push(`${c.bold('Security')} (OWASP MASVS) ${withIssues.length ? withIssues.join(' · ') : c.green('no issues')}${c.gray(` · ${groups.length} groups checked`)}`);
69
+ }
65
70
 
66
71
  if (n.critical + n.high > 0) {
67
72
  out.push('');
package/src/security.js CHANGED
@@ -46,6 +46,7 @@ export async function vulnerableDependencies(registry, versions) {
46
46
  const findings = [];
47
47
  for (const [name, list] of Object.entries(advisories)) {
48
48
  const version = versions[name];
49
+ if (!version || !Array.isArray(list)) continue; // only what we asked about
49
50
  // The endpoint answers for the versions we sent; double-check the range where it is simple.
50
51
  const relevant = list.filter((a) => applies(version, a.vulnerable_versions));
51
52
  if (!relevant.length) continue;
@@ -333,3 +334,316 @@ export function plainHttpCalls(root) {
333
334
  },
334
335
  ];
335
336
  }
337
+
338
+ // Firebase security rules kept in the repo. An open database or bucket is the most common way
339
+ // mobile app data leaks: anyone with the project id (it ships in the app) can read or wipe it.
340
+ export function cloudRules(root, deps, now = new Date()) {
341
+ const findings = [];
342
+ const dirs = [root, path.dirname(root), path.dirname(path.dirname(root))];
343
+ const findFile = (names) => {
344
+ for (const d of dirs) for (const n of names) if (fs.existsSync(path.join(d, n))) return path.join(d, n);
345
+ return null;
346
+ };
347
+ const rel = (f) => path.relative(root, f) || path.basename(f);
348
+ const usesFirebaseData = Object.keys(deps).some((n) => /^@react-native-firebase\/(database|firestore|storage)$|^firebase$/.test(n));
349
+
350
+ // Realtime Database (JSON). `.read`/`.write` set to true, or "auth != null" at the root.
351
+ const rtdb = findFile(['database.rules.json']);
352
+ if (rtdb) {
353
+ const text = read(rtdb);
354
+ const openWrite = /"\.write"\s*:\s*(?:true|"true")/.test(text);
355
+ const openRead = /"\.read"\s*:\s*(?:true|"true")/.test(text);
356
+ const rootAuthOnly = /"rules"\s*:\s*\{\s*"\.(?:read|write)"\s*:\s*"auth\s*!==?\s*null"/.test(text);
357
+ if (openWrite || openRead) {
358
+ findings.push(rulesFinding('firebase-rtdb-open', openWrite ? 'critical' : 'high', `Realtime Database rules let anyone ${openWrite ? 'write' : 'read'} (${rel(rtdb)})`, `A rule sets ${openWrite ? '".write"' : '".read"'} to true. The database URL ships inside the app, so anyone can ${openWrite ? 'change or delete your data' : 'download it'} without signing in.`, `In \`${rel(rtdb)}\`, replace the true rules with auth and owner checks (e.g. "auth.uid === $uid").`));
359
+ } else if (rootAuthOnly) {
360
+ findings.push(rulesFinding('firebase-rtdb-any-user', 'medium', `Realtime Database rules give every signed-in user the whole database (${rel(rtdb)})`, 'The root rule only checks "auth != null". Anyone who can create an account (or sign in anonymously) can read or write every user\'s data.', `In \`${rel(rtdb)}\`, scope the rules per path, e.g. "auth.uid === $uid".`));
361
+ }
362
+ }
363
+
364
+ // Firestore and Storage rules: `allow ...: if true`, a bare `allow read, write;`, or
365
+ // test-mode rules (`request.time < timestamp.date(...)`).
366
+ for (const [names, label, id] of [[['firestore.rules'], 'Firestore', 'firebase-firestore'], [['storage.rules'], 'Cloud Storage', 'firebase-storage']]) {
367
+ const file = findFile(names);
368
+ if (!file) continue;
369
+ const text = read(file).replace(/\/\/.*$/gm, '');
370
+ const testMode = text.match(/request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)/);
371
+ const { openWrite, openReadAll } = scanAllowRules(text);
372
+ if (testMode) {
373
+ const until = new Date(Date.UTC(Number(testMode[1]), Number(testMode[2]) - 1, Number(testMode[3])));
374
+ if (until > now) {
375
+ findings.push(rulesFinding(`${id}-test-mode`, 'critical', `${label} is in test mode until ${until.toISOString().slice(0, 10)} (${rel(file)})`, 'Test-mode rules allow every read and write from anyone until that date.', `Replace the test-mode rules in \`${rel(file)}\` with rules that check request.auth and the document owner.`));
376
+ } else {
377
+ findings.push(rulesFinding(`${id}-test-mode-expired`, 'high', `${label} test-mode rules expired on ${until.toISOString().slice(0, 10)} (${rel(file)})`, 'Past that date every request is denied, so the app cannot read or write its data, and the rules in the repo do not describe real access control.', `Write real rules in \`${rel(file)}\` (request.auth and owner checks).`));
378
+ }
379
+ } else if (openWrite) {
380
+ findings.push(rulesFinding(`${id}-open-write`, 'critical', `${label} rules let anyone write (${rel(file)}:${openWrite})`, 'A write rule has no condition (or "if true"). Your Firebase project id ships inside the app, so anyone can change or delete this data without signing in.', `In \`${rel(file)}\`, require request.auth (and the owner's uid) for every write.`));
381
+ } else if (openReadAll) {
382
+ findings.push(rulesFinding(`${id}-open-read`, 'high', `${label} rules let anyone read everything (${rel(file)}:${openReadAll})`, 'A recursive wildcard match ({path=**}) allows reads with no condition, so all data is public to anyone who has the project id (it ships inside the app).', `In \`${rel(file)}\`, limit public reads to the paths that are meant to be public.`));
383
+ }
384
+ }
385
+
386
+ if (!findings.length && usesFirebaseData && !rtdb && !findFile(['firestore.rules', 'storage.rules'])) {
387
+ findings.push({
388
+ id: 'firebase-rules-not-in-repo',
389
+ severity: 'info',
390
+ area: 'security',
391
+ title: 'Firebase data is used, but its security rules are not in the repository',
392
+ detail: 'Open database or storage rules are the most common way mobile app data leaks, and NativeKeel cannot see rules that live only in the Firebase console. Review them there (no "if true", no root-level "auth != null"), or keep them in the repo (firebase init) so they are reviewed and checked.',
393
+ });
394
+ }
395
+ return findings;
396
+ }
397
+
398
+ function rulesFinding(id, severity, title, detail, step) {
399
+ return { id, severity, area: 'security', title, detail, fix: { kind: 'security', step } };
400
+ }
401
+
402
+ // Line numbers of an unconditional write anywhere, and of an unconditional read in a match that
403
+ // is only a recursive wildcard (/{document=**}). Public reads of specific paths can be intentional.
404
+ function scanAllowRules(text) {
405
+ const lines = text.split('\n');
406
+ const stack = []; // match paths of the open blocks
407
+ let openWrite = 0;
408
+ let openReadAll = 0;
409
+ lines.forEach((line, i) => {
410
+ const code = line.replace(/\/\/.*$/, '');
411
+ const m = code.match(/match\s+(\S+)\s*\{/);
412
+ if (m) stack.push(m[1]);
413
+ const allow = code.match(/allow\s+([\w,\s]+?)\s*(?::\s*if\s+(.+?))?\s*;/);
414
+ if (allow) {
415
+ const unconditional = !allow[2] || /^true$/.test(allow[2].trim());
416
+ const ops = allow[1];
417
+ if (unconditional && /\b(write|create|update|delete)\b/.test(ops)) openWrite = openWrite || i + 1;
418
+ if (unconditional && /\b(read|get|list)\b/.test(ops) && /^\/\{[^}]*=\*\*\}$/.test(stack.at(-1) || '')) openReadAll = openReadAll || i + 1;
419
+ }
420
+ const closes = (code.match(/\}/g) || []).length - (code.match(/\{/g) || []).length;
421
+ for (let k = 0; k < closes && stack.length; k++) stack.pop();
422
+ });
423
+ return { openWrite, openReadAll };
424
+ }
425
+
426
+ // Native code that turns off TLS certificate checks: anyone on the same Wi-Fi can read and
427
+ // change the app's HTTPS traffic (MASVS-NETWORK-1).
428
+ export function insecureTls(root) {
429
+ const files = [];
430
+ const collect = (dir, exts) => {
431
+ let entries = [];
432
+ try {
433
+ entries = fs.readdirSync(dir, { withFileTypes: true });
434
+ } catch {
435
+ return;
436
+ }
437
+ for (const e of entries) {
438
+ const full = path.join(dir, e.name);
439
+ if (e.isDirectory()) {
440
+ if (!['Pods', 'build', 'node_modules', '.gradle', 'DerivedData', 'test', 'androidTest'].includes(e.name) && !/Tests?$/.test(e.name)) collect(full, exts);
441
+ } else if (exts.test(e.name)) {
442
+ files.push(full);
443
+ }
444
+ }
445
+ };
446
+ collect(path.join(root, 'android'), /\.(java|kt)$/);
447
+ collect(path.join(root, 'ios'), /\.(m|mm|swift)$/);
448
+ const PATTERNS = [
449
+ { re: /checkServerTrusted\s*\([^)]*\)\s*(?:throws[^{]*)?(?::\s*Unit\s*)?\{\s*\}/, what: 'a TrustManager whose checkServerTrusted accepts every certificate' },
450
+ { re: /(?:ALLOW_ALL_HOSTNAME_VERIFIER|NoopHostnameVerifier|AllowAllHostnameVerifier)|verify\s*\(\s*\w+\s*:?\s*String\??[^)]*\)\s*(?::\s*Boolean\s*)?(?:\{\s*return\s+true\s*;?\s*\}|=\s*true)|hostnameVerifier\s*\{\s*_\s*,\s*_\s*->\s*true\s*\}/, what: 'a hostname verifier that accepts every host' },
451
+ { re: /onReceivedSslError[\s\S]{0,300}?\.proceed\s*\(\s*\)/, what: 'a WebView that proceeds on SSL errors' },
452
+ { re: /allowsAnyHTTPSCertificateForHost|setAllowsAnyHTTPSCertificate/, what: 'an iOS API that accepts any HTTPS certificate' },
453
+ ];
454
+ const hits = [];
455
+ // A setting like "ignore TLS errors" or "trust self-signed" anywhere in the native code: the
456
+ // bypass is often defined in one file and switched on from another.
457
+ const optInSetting = /(?:ignore\w*(?:ssl|tls|cert)\w*|trustSelfSigned|allowSelfSigned|selfSignedCert)/i;
458
+ const projectOptIn = files.some((f) => optInSetting.test(read(f)));
459
+ for (const f of files) {
460
+ const text = read(f);
461
+ if (!text) continue;
462
+ // A bypass behind a setting (trust a self-signed home server, an "ignore TLS errors" switch)
463
+ // is an opt-in feature: still worth reviewing, but not the same as trusting everything.
464
+ const OPT_IN = /\bif\s*\(?[^\n]*(?:validate|verif|trust|self.?signed|ignore\w*(?:ssl|tls|cert)|insecure|allowInvalid|acceptInvalid|unsafe)/i;
465
+ const optIn = (index) => {
466
+ const before = text.slice(Math.max(0, index - 1200), index).split('\n').slice(-25).join('\n');
467
+ return OPT_IN.test(before) || projectOptIn;
468
+ };
469
+ for (const p of PATTERNS) {
470
+ const m = text.match(p.re);
471
+ if (m) hits.push({ file: path.relative(root, f), line: text.slice(0, m.index).split('\n').length, what: p.what, optIn: optIn(m.index) });
472
+ }
473
+ // iOS: answering a server-trust challenge with the server's own trust, without evaluating it.
474
+ if (/\.(m|mm|swift)$/.test(f) && /(?:URLCredential\(\s*trust:|credentialForTrust:)/.test(text) && !/SecTrustEvaluate|SecTrustEvaluateWithError|evaluate\(/.test(text)) {
475
+ const idx = text.search(/URLCredential\(\s*trust:|credentialForTrust:/);
476
+ hits.push({ file: path.relative(root, f), line: text.slice(0, idx).split('\n').length, what: 'a URL session that trusts the server certificate without evaluating it', optIn: optIn(idx) });
477
+ }
478
+ }
479
+ const findings = hits.map((h) =>
480
+ h.optIn
481
+ ? {
482
+ id: `insecure-tls:${h.file}`,
483
+ severity: 'high',
484
+ area: 'security',
485
+ title: `TLS certificate checks can be switched off (${h.file}:${h.line})`,
486
+ detail: `This file has ${h.what}, behind a setting (it looks like an opt-in for self-signed or self-hosted servers). While it is on, anyone on the same network can read and change that traffic. Keep it off by default, limit it to the one server the user chose (or pin that server's certificate), and say so clearly in the UI. (OWASP MASVS-NETWORK-1)`,
487
+ fix: { kind: 'security', step: `Review the certificate bypass in \`${h.file}:${h.line}\`: off by default, limited to the user's own server, ideally certificate pinning instead.` },
488
+ }
489
+ : {
490
+ id: `insecure-tls:${h.file}`,
491
+ severity: 'critical',
492
+ area: 'security',
493
+ title: `TLS certificate checks are turned off (${h.file}:${h.line})`,
494
+ detail: `This file has ${h.what}, with no setting around it. Anyone on the same network (public Wi-Fi, a compromised router) can read and change the app's HTTPS traffic, including logins and tokens. Remove it; for a self-signed development server, use a debug-only network security config instead. (OWASP MASVS-NETWORK-1)`,
495
+ fix: { kind: 'security', step: `Remove the certificate bypass in \`${h.file}:${h.line}\` (${h.what}).` },
496
+ },
497
+ );
498
+
499
+ // Release builds that trust user-installed certificates: a classic interception setup.
500
+ const xmlDir = path.join(root, 'android', 'app', 'src', 'main', 'res', 'xml');
501
+ let xmls = [];
502
+ try {
503
+ xmls = fs.readdirSync(xmlDir).filter((f) => f.endsWith('.xml'));
504
+ } catch {
505
+ // no res/xml
506
+ }
507
+ for (const x of xmls) {
508
+ const text = read(path.join(xmlDir, x)).replace(/<debug-overrides>[\s\S]*?<\/debug-overrides>/g, '');
509
+ if (/<network-security-config/.test(text) && /<certificates\s+src\s*=\s*"user"/.test(text)) {
510
+ findings.push({
511
+ id: `user-certificates:${x}`,
512
+ severity: 'low',
513
+ area: 'security',
514
+ title: `Release builds trust user-installed certificates (res/xml/${x})`,
515
+ detail: 'A certificate the user (or malware, or a device profile) installs can intercept the app\'s HTTPS traffic. Apps for company servers with a private CA sometimes need this; otherwise keep <certificates src="user"/> inside <debug-overrides> only. (OWASP MASVS-NETWORK-1)',
516
+ fix: { kind: 'security', step: `In \`android/app/src/main/res/xml/${x}\`, move \`<certificates src="user"/>\` into \`<debug-overrides>\`.` },
517
+ });
518
+ }
519
+ }
520
+ return findings;
521
+ }
522
+
523
+ // Cryptography that looks like protection but is not (MASVS-CRYPTO-1/2), and session tokens in
524
+ // unencrypted storage (MASVS-STORAGE-1).
525
+ const CRYPTO_RULES = [
526
+ { id: 'hardcoded-key', re: /\b(?:CryptoJS\.(?:AES|DES|TripleDES|Rabbit|RC4)\.(?:encrypt|decrypt)\s*\([^,()]+,\s*['"`][^'"`]{4,}['"`]|createCipheriv\s*\(\s*['"][^'"]+['"]\s*,\s*['"`][^'"`]{4,}['"`])/, severity: 'high', title: 'Encryption key hardcoded in the app', detail: 'The key is a string in the JavaScript bundle, so anyone can extract it and decrypt the data. Derive keys per user (e.g. from the Keychain/Keystore) or keep encryption on the server.' },
527
+ { id: 'weak-password-hash', re: /\b(?:md5|sha1|MD5|SHA1)\s*\(\s*[^)]*\bpass(?:word|wd)?\b/i, severity: 'high', title: 'Password hashed with MD5 or SHA-1', detail: 'MD5 and SHA-1 are fast and broken for passwords: anyone who captures the hash cracks it in minutes. To store or verify passwords, let the server use bcrypt, scrypt or Argon2; to derive an encryption key from a password, use PBKDF2, scrypt or Argon2; if a server protocol forces it, prefer that server\'s token or API-key login.' },
528
+ { id: 'ecb-mode', re: /CryptoJS\.mode\.ECB|['"]aes-\d+-ecb['"]/i, severity: 'medium', title: 'Encryption in ECB mode', detail: 'ECB encrypts equal blocks to equal output, so patterns in the data stay visible. Use an authenticated mode such as AES-GCM.' },
529
+ { id: 'insecure-random', re: /\b(?:nonce|secret|otp|salt|password|passcode|apikey|api_key|csrf|verifier|pkce)\w*\s*[=:]\s*[^;\n]*Math\.random\s*\(/i, severity: 'medium', title: 'Math.random used for a security value', detail: 'Math.random is predictable. Use crypto.getRandomValues (react-native-get-random-values or expo-crypto) for tokens, nonces, salts and codes.' },
530
+ ];
531
+
532
+ export function weakCrypto(root) {
533
+ const byRule = new Map();
534
+ for (const file of jsFiles(root)) {
535
+ const text = read(file);
536
+ if (!text || !/CryptoJS|createCipheriv|md5|sha1|MD5|SHA1|Math\.random/.test(text)) continue;
537
+ // Comments describing an algorithm are not code.
538
+ const code = text.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, ' ')).replace(/\/\/[^\n]*/g, '');
539
+ for (const rule of CRYPTO_RULES) {
540
+ const m = code.match(rule.re);
541
+ if (m && !byRule.has(rule.id)) byRule.set(rule.id, { rule, where: `${path.relative(root, file)}:${code.slice(0, m.index).split('\n').length}` });
542
+ }
543
+ }
544
+ return [...byRule.values()].map(({ rule, where }) => ({
545
+ id: `crypto-${rule.id}`,
546
+ severity: rule.severity,
547
+ area: 'security',
548
+ title: `${rule.title} (${where})`,
549
+ detail: `${rule.detail} (OWASP MASVS-CRYPTO)`,
550
+ fix: { kind: 'security', step: `${rule.title} at \`${where}\`: ${rule.detail.split('. ').slice(-1)[0]}` },
551
+ }));
552
+ }
553
+
554
+ // Auth tokens written to AsyncStorage: on a rooted/jailbroken device, or from a backup, they
555
+ // can be read and reused to act as the user.
556
+ export function tokenStorage(root) {
557
+ const hits = [];
558
+ for (const file of jsFiles(root)) {
559
+ const text = read(file);
560
+ if (!text || !/AsyncStorage|MMKV/.test(text)) continue;
561
+ for (const m of text.matchAll(/\b(?:AsyncStorage\.setItem|storage\.set|mmkv\.set)\s*\(\s*['"`@]?([\w@.\-:]*?(?:access_?token|refresh_?token|auth_?token|id_?token|jwt|session_?token|bearer)[\w.\-:]*)['"`]?\s*,/gi)) {
562
+ hits.push(`${m[1]} (${path.relative(root, file)}:${text.slice(0, m.index).split('\n').length})`);
563
+ }
564
+ }
565
+ if (!hits.length) return [];
566
+ return [
567
+ {
568
+ id: 'token-unencrypted-storage',
569
+ severity: 'low',
570
+ area: 'security',
571
+ title: `Auth token${hits.length === 1 ? '' : 's'} stored without encryption`,
572
+ detail: `${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', …' : ''}. AsyncStorage (and unencrypted MMKV) is plain files: backups and rooted devices expose them, and a copied token signs in as the user. Keep tokens in the Keychain/Keystore (react-native-keychain, expo-secure-store). (OWASP MASVS-STORAGE-1)`,
573
+ fix: { kind: 'security', step: 'Move auth tokens from AsyncStorage to the Keychain/Keystore (`react-native-keychain` or `expo-secure-store`).' },
574
+ },
575
+ ];
576
+ }
577
+
578
+ // OWASP MASVS control group of a security finding, by id. Shown in reports so a security
579
+ // reviewer can map findings to the standard they audit against.
580
+ export const MASVS_GROUPS = {
581
+ STORAGE: 'Data on the device',
582
+ CRYPTO: 'Keys and cryptography',
583
+ AUTH: 'Access control (backend rules)',
584
+ NETWORK: 'Network traffic',
585
+ PLATFORM: 'WebViews and platform interaction',
586
+ CODE: 'Dependencies and build',
587
+ RESILIENCE: 'Reverse engineering',
588
+ };
589
+
590
+ export function masvsOf(id) {
591
+ if (/^(secret:|env-bundled|crypto-)/.test(id)) return 'CRYPTO';
592
+ if (/^(password-leak|token-unencrypted|android-allow-backup)/.test(id)) return 'STORAGE';
593
+ if (/^(insecure-tls|user-certificates|plain-http|ios-ats|android-cleartext)/.test(id)) return 'NETWORK';
594
+ if (/^(webview-|android-exported)/.test(id)) return 'PLATFORM';
595
+ if (/^firebase-/.test(id)) return 'AUTH';
596
+ if (/^(vuln:|signing-|android-debuggable)/.test(id)) return 'CODE';
597
+ if (/^(sourcemap-in-app|easy-reverse-engineering)/.test(id)) return 'RESILIENCE';
598
+ return null;
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
+ }