nativekeel 0.1.15 → 0.1.16

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.15:** run it at a monorepo root and it lists the app folders to scan. **0.1.14:** secrets in Expo config (`extra`, `EXPO_PUBLIC_`, `eas.json`) and imports of APIs removed from React Native core. Recent releases added a *Start here* list in every report, hacking risks mapped to OWASP MASVS, crash and performance checks. Every check is 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.16:** deep-link checks: unverified Android App Links, and the template `myapp://` scheme that other apps can claim (MASVS-PLATFORM). **0.1.15:** run it at a monorepo root and it lists the app folders to scan. **0.1.14:** secrets in Expo config (`extra`, `EXPO_PUBLIC_`, `eas.json`) and imports of APIs removed from React Native core. Recent releases added a *Start here* list in every report, hacking risks mapped to OWASP MASVS, crash and performance checks. Every check is tuned on 450+ open-source React Native and Expo apps to keep false alarms out. [All releases →](https://nativekeel.com/changelog)
10
10
 
11
11
  ```
12
12
  Summary 4 critical · 5 high · 4 medium · 6 low
@@ -38,7 +38,7 @@ Start here
38
38
  - **Network:** TLS certificate checks turned off in native code, cleartext HTTP, `NSAllowsArbitraryLoads`, plain-HTTP API calls, user-installed CAs in release.
39
39
  - **Backend rules:** Firebase Realtime Database, Firestore and Storage rules open to anyone or in test mode.
40
40
  - **Cryptography:** hardcoded encryption keys, MD5/SHA-1 on passwords, ECB mode, `Math.random` for nonces and salts.
41
- - **Platform:** WebViews that read local files or load HTTP into HTTPS, Android components any app can start.
41
+ - **Platform:** WebViews that read local files or load HTTP into HTTPS, Android components any app can start, unverified deep links and the template `myapp://` URL scheme.
42
42
  - **Dependencies and reverse engineering:** known vulnerabilities in the exact versions you ship (GitHub Advisory Database), source maps in the app, Hermes or R8 off.
43
43
 
44
44
  These are static checks: they catch the common, detectable mistakes, not everything a penetration test of the running app and backend would.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
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
@@ -6,7 +6,7 @@ import { scanSecrets } from './secrets.js';
6
6
  import { nativeChecks } from './native.js';
7
7
  import { stabilityChecks } from './stability.js';
8
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
+ import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, reverseEngineering, expoConfigSecrets, deepLinks, masvsOf, MASVS_GROUPS } from './security.js';
10
10
  import { lockedVersions } from './lockfile.js';
11
11
  import { findUnused, filesImportingWith } from './usage.js';
12
12
  import { matchKnownIssues, compareVersions } from './known-issues.js';
@@ -408,7 +408,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
408
408
  for (const f of performanceChecks(project)) add(f);
409
409
 
410
410
  // Security beyond leaked keys.
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);
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), ...deepLinks(project.root, project.deps)]) add(f);
412
412
  if (!forcedOffline) {
413
413
  // Exact versions only (installed, else the lockfile): a range would be a guess.
414
414
  const locked = lockedVersions(project.root, project.deps);
package/src/native.js CHANGED
@@ -200,7 +200,8 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
200
200
  patched.push({ file: f, pkg, version });
201
201
  const text = read(path.join(patchDir, f)) || '';
202
202
  const buildPaths = (text.match(/^diff --git a\/node_modules\/\S+?\/(?:android|ios)\/(?:build|\.cxx|\.gradle)\//gm) || []).length;
203
- const binary = /^GIT binary patch|^Binary files /m.test(text);
203
+ // Finder/Explorer junk (.DS_Store, Thumbs.db) caught in a patch is harmless noise.
204
+ const binary = text.split(/^diff --git /m).some((part) => /^GIT binary patch|^Binary files /m.test(part) && !/^\S*?(?:\.DS_Store|Thumbs\.db)\b/.test(part));
204
205
  if (buildPaths || binary) {
205
206
  add({
206
207
  id: `patch-artifacts:${f}`,
@@ -400,7 +401,7 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
400
401
  return false;
401
402
  }
402
403
  };
403
- const signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug/i.test(path.basename(f)) && nonEmpty(f));
404
+ const signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug|fake|dummy/i.test(path.basename(f)) && !/(?:^|\/)(?:__tests__|tests?|e2e|fixtures?)\//.test(f) && nonEmpty(f));
404
405
  for (const f of signing) {
405
406
  add({
406
407
  id: `signing-keystore:${f}`,
@@ -60,6 +60,7 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:32px 0 12px}h2 small{f
60
60
  .stat.critical b{color:var(--critical)}.stat.high b{color:var(--high)}
61
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}
62
62
  ul.list{list-style:none;padding:0;margin:0;display:grid;gap:8px}
63
+ main{overflow-wrap:anywhere}code{overflow-wrap:anywhere;word-break:break-word}.f>div,.sg,.stats>*{min-width:0}.facts{display:flex;flex-wrap:wrap;gap:6px 16px}
63
64
  .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
65
  .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}}
65
66
  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}
package/src/security.js CHANGED
@@ -591,7 +591,7 @@ export function masvsOf(id) {
591
591
  if (/^(secret:|env-bundled|crypto-|expo-secret)/.test(id)) return 'CRYPTO';
592
592
  if (/^(password-leak|token-unencrypted|android-allow-backup)/.test(id)) return 'STORAGE';
593
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';
594
+ if (/^(webview-|android-exported|deeplink-)/.test(id)) return 'PLATFORM';
595
595
  if (/^firebase-/.test(id)) return 'AUTH';
596
596
  if (/^(vuln:|signing-|android-debuggable)/.test(id)) return 'CODE';
597
597
  if (/^(sourcemap-in-app|easy-reverse-engineering)/.test(id)) return 'RESILIENCE';
@@ -711,3 +711,94 @@ export function expoConfigSecrets(root) {
711
711
  }
712
712
  return findings;
713
713
  }
714
+
715
+ // Deep links (MASVS-PLATFORM). A web link (https://your.domain/...) opens the app only when the
716
+ // domain is verified (autoVerify + assetlinks.json); without it Android 12+ opens the browser,
717
+ // and older Android lets any app that registers the same link receive it. Custom URL schemes can
718
+ // be registered by any app, so the template scheme "myapp" is shared with every other app that
719
+ // kept it, and an OAuth redirect to it can land in the wrong app.
720
+ const TEMPLATE_SCHEMES = new Set(['myapp', 'my-app', 'myscheme', 'your-app-scheme', 'yourappscheme']);
721
+ const SENSITIVE_LINK = /auth|login|signin|sign-in|reset|verify|magic|invite|oauth|callback|token/i;
722
+ const OAUTH_DEPS = ['expo-auth-session', '@clerk/clerk-expo', 'react-native-app-auth', '@react-native-community/oauth', 'react-native-auth0'];
723
+
724
+ export function deepLinks(root, deps = {}) {
725
+ const findings = [];
726
+ const unverified = new Map(); // host -> sensitive?
727
+ const schemes = new Set();
728
+ const realHost = (h) => h && !/[*$@{]/.test(h) && h.includes('.') && !/^(?:localhost|127\.|10\.|192\.168\.)/.test(h);
729
+
730
+ // autoVerify on any one filter makes Android verify every web host in the manifest.
731
+ const manifest = read(path.join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml')) || '';
732
+ const anyVerified = /<intent-filter\b[^>]*android:autoVerify\s*=\s*"true"/.test(manifest);
733
+ for (const m of manifest.matchAll(/<intent-filter\b([^>]*)>([\s\S]*?)<\/intent-filter>/g)) {
734
+ const body = m[2];
735
+ if (!/android\.intent\.category\.BROWSABLE/.test(body)) continue;
736
+ const found = [...body.matchAll(/android:scheme\s*=\s*"([^"]+)"/g)].map((x) => x[1]);
737
+ for (const s of found) if (!/^https?$/.test(s)) schemes.add(s.toLowerCase());
738
+ if (anyVerified || !found.some((s) => /^https?$/.test(s))) continue;
739
+ const paths = [...body.matchAll(/android:path(?:Prefix|Pattern)?\s*=\s*"([^"]+)"/g)].map((x) => x[1]).join(' ');
740
+ for (const h of body.matchAll(/android:host\s*=\s*"([^"]+)"/g)) {
741
+ if (realHost(h[1])) unverified.set(h[1], unverified.get(h[1]) || SENSITIVE_LINK.test(`${h[1]} ${paths}`));
742
+ }
743
+ }
744
+
745
+ let app = null;
746
+ try {
747
+ app = JSON.parse(read(path.join(root, 'app.json')) || 'null');
748
+ } catch {
749
+ // not JSON
750
+ }
751
+ const expo = (app && (app.expo || app)) || {};
752
+ for (const s of [].concat(expo.scheme || [])) if (typeof s === 'string') schemes.add(s.toLowerCase());
753
+ for (const f of ['app.config.js', 'app.config.ts']) {
754
+ const text = read(path.join(root, f)) || '';
755
+ for (const m of text.matchAll(/\bscheme\s*:\s*['"`]([\w.+-]+)['"`]/g)) schemes.add(m[1].toLowerCase());
756
+ }
757
+ const expoFilters = (expo.android && expo.android.intentFilters) || [];
758
+ for (const filter of expoFilters.some((f) => f && f.autoVerify) ? [] : expoFilters) {
759
+ if (!filter || ![].concat(filter.category || []).includes('BROWSABLE')) continue;
760
+ for (const d of [].concat(filter.data || [])) {
761
+ if (d && /^https?$/.test(d.scheme || '') && realHost(d.host)) {
762
+ unverified.set(d.host, unverified.get(d.host) || SENSITIVE_LINK.test(`${d.host} ${d.pathPrefix || ''} ${d.path || ''} ${d.pathPattern || ''}`));
763
+ }
764
+ }
765
+ }
766
+ let iosDirs = [];
767
+ try {
768
+ iosDirs = fs.readdirSync(path.join(root, 'ios'), { withFileTypes: true }).filter((e) => e.isDirectory() && !/^(?:Pods|build)$|Tests$|\.xc/.test(e.name));
769
+ } catch {
770
+ // no ios folder
771
+ }
772
+ for (const d of iosDirs) {
773
+ const plist = read(path.join(root, 'ios', d.name, 'Info.plist')) || '';
774
+ for (const block of plist.match(/<key>CFBundleURLSchemes<\/key>\s*<array>[\s\S]*?<\/array>/g) || []) {
775
+ for (const s of block.matchAll(/<string>([^<$]+)<\/string>/g)) schemes.add(s[1].toLowerCase());
776
+ }
777
+ }
778
+
779
+ if (unverified.size) {
780
+ const hosts = [...unverified.keys()];
781
+ const sensitive = hosts.filter((h) => unverified.get(h));
782
+ findings.push({
783
+ id: 'deeplink-unverified',
784
+ severity: sensitive.length ? 'medium' : 'low',
785
+ area: 'security',
786
+ title: `Web links to ${hosts.slice(0, 2).join(', ')}${hosts.length > 2 ? ` and ${hosts.length - 2} more` : ''} are not verified (no autoVerify)`,
787
+ detail: `On Android 12+ these links open in the browser instead of the app, and on older Android any installed app that registers the same links can receive them${sensitive.length ? ', including the login/reset/invite links that carry tokens' : ''}. For domains you own, add android:autoVerify="true" to the intent filter and publish /.well-known/assetlinks.json with your signing certificate. (OWASP MASVS-PLATFORM)`,
788
+ fix: { kind: 'security', step: `Add \`android:autoVerify="true"\` to the https intent filters for ${hosts.slice(0, 3).map((h) => `\`${h}\``).join(', ')} and serve \`https://${hosts[0]}/.well-known/assetlinks.json\`.` },
789
+ });
790
+ }
791
+ const template = [...schemes].filter((s) => TEMPLATE_SCHEMES.has(s));
792
+ if (template.length) {
793
+ const oauth = OAUTH_DEPS.filter((d) => deps[d]);
794
+ findings.push({
795
+ id: 'deeplink-template-scheme',
796
+ severity: oauth.length ? 'medium' : 'low',
797
+ area: 'security',
798
+ title: `The app still uses the template URL scheme "${template[0]}://"`,
799
+ detail: `Many other apps kept the same scheme, so ${template[0]}:// links can open a different app${oauth.length ? `, and the sign-in redirect of ${oauth.join(', ')} can be delivered to it` : ''}. Pick a scheme unique to your app (for example your bundle identifier). (OWASP MASVS-PLATFORM)`,
800
+ fix: { kind: 'security', step: `Replace the "${template[0]}" scheme with one unique to the app (app.json \`scheme\`, AndroidManifest and Info.plist), and update redirect URIs registered with your sign-in providers.` },
801
+ });
802
+ }
803
+ return findings;
804
+ }