nativekeel 0.1.11 → 0.1.12

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.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,8 @@ It answers the questions that decide whether your app can still ship:
18
18
  - **Are secrets shipped inside your app?** AWS, Stripe, OpenAI, Anthropic, GitHub, Slack, SendGrid, Twilio, Shopify, Google OAuth keys, private keys and Supabase `service_role` keys (anon keys are fine), plus `.env` values that `react-native-config` or `EXPO_PUBLIC_` compile into the bundle. Server-side folders are reported separately.
19
19
  - **Is transport security off?** Cleartext HTTP or `debuggable` in the release Android manifest, `NSAllowsArbitraryLoads` on iOS.
20
20
  - **Do you ship known vulnerabilities?** Advisories from the GitHub Advisory Database for the exact versions in your lockfile or `node_modules`. Advisories that only affect Node.js servers are kept, at low severity.
21
- - **Can someone read your users' data?** Passwords that flow into crash reports, analytics, remote databases or plain AsyncStorage (followed through intermediate variables), WebViews that let page scripts read local files or load HTTP into HTTPS, Android backups that copy tokens, 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.
22
23
  - **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
24
  - **Leftovers from old templates?** Flipper, JavaScriptCore instead of Hermes.
24
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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,7 @@ 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 { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, masvsOf, MASVS_GROUPS } from './security.js';
9
9
  import { lockedVersions } from './lockfile.js';
10
10
  import { findUnused, filesImportingWith } from './usage.js';
11
11
  import { matchKnownIssues, compareVersions } from './known-issues.js';
@@ -405,12 +405,16 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
405
405
  for (const f of stabilityChecks(project)) add(f);
406
406
 
407
407
  // Security beyond leaked keys.
408
- for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root), ...exportedComponents(project.root), ...plainHttpCalls(project.root)]) add(f);
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);
409
409
  if (!forcedOffline) {
410
410
  // Exact versions only (installed, else the lockfile): a range would be a guess.
411
411
  const locked = lockedVersions(project.root, project.deps);
412
412
  const exact = {};
413
413
  for (const name of Object.keys(project.deps)) {
414
+ // Advisories describe npm registry packages. A dependency from git, a URL or a local
415
+ // path is a different package that only shares the name (e.g. a squatted name with a
416
+ // malware advisory), so it is not matched.
417
+ if (/:\/\/|^(github|gitlab|bitbucket|git|file|link|workspace|portal|patch):|^[\w.-]+\/[\w.-]+(#|$)/.test(String(project.deps[name]))) continue;
414
418
  const v = installedVersion(project.root, name) || locked[name];
415
419
  if (v && /^\d+\.\d+\.\d+/.test(v)) exact[name] = v;
416
420
  }
@@ -453,6 +457,14 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
453
457
  }
454
458
 
455
459
  reportIncomplete();
460
+ // OWASP MASVS group for every security finding, and a per-group count (zero means checked, clean).
461
+ const security = Object.fromEntries(Object.keys(MASVS_GROUPS).map((g) => [g, 0]));
462
+ for (const f of findings) {
463
+ const g = masvsOf(f.id);
464
+ if (!g) continue;
465
+ f.masvs = g;
466
+ if (f.severity !== 'info') security[g]++;
467
+ }
456
468
  findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
457
469
  return {
458
470
  tool: 'nativekeel',
@@ -467,6 +479,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
467
479
  android: project.android,
468
480
  },
469
481
  latest: { reactNative: rn && rn.latest, expo: expo && expo.latest },
482
+ security,
470
483
  offline,
471
484
  offlineRequested: forcedOffline,
472
485
  warnings,
package/src/plan.js CHANGED
@@ -274,21 +274,25 @@ export function buildPlan(result) {
274
274
  });
275
275
  }
276
276
 
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.'}`);
277
+ // Security steps, most severe first (they feed "Start here").
278
+ const sevRank = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
279
+ const sec = [];
280
+ for (const f of result.findings) {
281
+ if (!f.fix) continue;
282
+ const sev = sevRank[f.severity] ?? 4;
283
+ if (f.fix.kind === 'manifest') {
284
+ 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.' });
285
+ } else if (f.fix.kind === 'ats') {
286
+ sec.push({ sev, step: `In \`${f.fix.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.` });
287
+ } else if (f.fix.kind === 'security') {
288
+ sec.push({ sev, step: f.fix.step });
289
+ } else if (f.fix.kind === 'vuln-dep' && (f.fix.severity === 'critical' || f.fix.severity === 'high')) {
290
+ const v = f.fix;
291
+ 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.'}` });
292
+ }
290
293
  }
291
- const minorVulns = byKind('vuln-dep').length - vulns.length;
294
+ const securitySteps = sec.sort((a, b) => a.sev - b.sev).map((x) => x.step);
295
+ const minorVulns = byKind('vuln-dep').filter((v) => v.severity !== 'critical' && v.severity !== 'high').length;
292
296
  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
297
  if (securitySteps.length) {
294
298
  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 });
@@ -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,264 @@ 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
+ };
588
+
589
+ export function masvsOf(id) {
590
+ if (/^(secret:|env-bundled|crypto-)/.test(id)) return 'CRYPTO';
591
+ if (/^(password-leak|token-unencrypted|android-allow-backup)/.test(id)) return 'STORAGE';
592
+ if (/^(insecure-tls|user-certificates|plain-http|ios-ats|android-cleartext)/.test(id)) return 'NETWORK';
593
+ if (/^(webview-|android-exported)/.test(id)) return 'PLATFORM';
594
+ if (/^firebase-/.test(id)) return 'AUTH';
595
+ if (/^(vuln:|signing-|android-debuggable)/.test(id)) return 'CODE';
596
+ return null;
597
+ }