Skip to content
Back to skills

Mobile Security Scan

BSecurity

On-device security for Android, iOS, React Native and Flutter — storage, transport, IPC, links, WebViews, release flags, shipped secrets.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
developmentjavascriptrustgojavaswiftkotlinshellbashreactnode

Works with

  • cli
  • api

Security analysis

B88/100
  • criticalSends environment variables or credentials to an external URL

Pro shows the line behind each finding and how to fix it

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill mobile-security-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Mobile Security Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Mobile Security Scan
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-mobile-security-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-mobile-security-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: mobile-security-scan
description: On-device security for Android, iOS, React Native and Flutter — storage, transport, IPC, links, WebViews, release flags, shipped secrets.
kind: skill
pack: security
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: mobile-security-scan

## Premise

A mobile app ships its client to the attacker. Whatever the binary holds, whatever the sandbox stores, whatever the manifest exports and whatever a WebView bridges is reachable by someone the server never authenticates: **another app on the same device**, **a network attacker** on the same Wi-Fi, **whoever holds the shipped APK / IPA**, **a web page** the app loads, and, for the L2 classes, **someone holding the device or its backup**. The rest of this pack is server-centric — before this skill it had no client-side mobile lane at all. This skill is that lane: `/security-audit` dispatches it on any `android/`, `ios/`, React Native or Flutter tree, and on every whole-repo run.

**Every finding cites the config or code at `<file:line>` + the attacker capability it assumes + the concrete fix + a severity calibrated to its MASWE profile.** A grep hit is never a finding, and "no mobile issues" is never printed without the class-coverage block (§ Output). Third-party scanners do not cover this ground: mobsfscan reads Java, Kotlin, Android XML, Info.plist, Swift and Objective-C only — **no Dart, no JS/TS** (mobsfscan 1.0.1, 2026-09-21) — so React Native and Flutter app code is covered by the greps below or by nothing.

## The claim, restated for mobile

Before any gate of [`finding-verification.md`](../../../../snippets/finding-verification.md), write the candidate as one sentence (its § 1):

> **[A malicious app on the device · a network attacker with no trusted CA · whoever holds the APK/IPA · a page the WebView loads · someone holding the device or its backup]** uses **[the exported component, link, cleartext socket, bridge, file or string at file:line]** in a **release** build to gain **[the session, another user's data, a privileged native action, a server secret]**, because **[the control that is absent]**.

Gate 1 (reachability) means the **release** build: not under `__DEV__`, `kDebugMode`, `BuildConfig.DEBUG`, `#if DEBUG`, a `src/debug/` source set or a test target. Gate 3 (no effective control) is read on the **effective** config: the merged `AndroidManifest.xml` (libraries inject components and permissions), the signed entitlements, the Expo prebuild output. Gate 6 (environment) includes the platform defaults that already protect: cleartext is off by default from `targetSdk` 28, user-installed CAs are untrusted from 24, StrandHogg is moot at `minSdk` ≥ 30. The calibration the verifier applies is [`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 42–48.

## Severity — this pack's scale, calibrated by MASWE profile

The pack's severities (critical / high / medium / low) and buckets (Finding / Unconfirmed / Hardening / Refuted) are unchanged. What moves a row between them is the OWASP MASWE profile of its weakness (MASWE repo, 2026-09-30; MASVS 2.1.0):

- **L1** — a defect for every app: data in logs (MASWE-0005), not excluded from backup (0006), hardcoded in the package (0004), stored outside private storage (0002), cleartext (0026), broken certificate validation (0027), deep links (0029), intents (0032), native functionality exposed to a WebView (0033), WebView local resources and untrusted content (0034, 0035).
- **L2 only** — **Hardening** unless the project declares an L2 posture (a threat model, security rule, ADR or README naming MASVS-L2 or the regime that demands it): a token in **app-private** plaintext storage (MASWE-0001), missing or misconfigured identity pinning (0028), screenshots and screen recording of sensitive screens (0038). Declared L2 → a Finding at medium (high for a refresh token or card data).
- **R (resilience)** — root / jailbreak detection, obfuscation, anti-tamper are **never a finding by their absence**; they appear only in the § 8 inventory. Debug artifacts and debuggable release builds (0061, 0063) are the exception the pack has always rated: a debuggable or debug-signed release is high.

## Boundaries

This skill does not re-derive classes another skill owns; it cross-links them:
- **Committed keystores, credentials across git history, rotation** → `secret-scan-method`. A key file in the tree printed by grep 6b is credited there; § 7 here owns what the **shipped app** carries. `EXPO_PUBLIC_*` names are also `xss-scan` § 12a's grep: one finding per key, credited to whichever run proved it in the bundle, the other listed as corroboration.
- **XSS inside the web content a WebView loads** → `xss-scan`. **Bridge exposure** (what that content can call natively) is § 5 here.
- **Server-side** `verify=False` / `rejectUnauthorized: false` in backend code, server log redaction → `injection-scan` / `data-exposure-scan`. Device logs (logcat, os_log, console in a release bundle) are § 6 here.
- **A link payload that crashes or races auth at cold start** (robustness) → `mobile-lifecycle-scan` § 5 when the mobile pack is installed; without it, report the crash here at low, tagged `robustness`. Link **hijack** and links reaching a WebView or a privileged screen are § 4 here.
- **Keychain items surviving uninstall** (no first-launch wipe) and cross-account residue at logout → `offline-sync-scan` § 5 when the mobile pack is installed; without it, § 1 reports the missing wipe as medium.
- **Store policy** (ATS justification for App Review, privacy manifest, target API) → `store-compliance-scan` when the mobile pack is installed. A store gate never gets a security severity, and a security finding never gets a store date.
- **New-Architecture bridge threading, native-module contracts** → `native-bridge-audit` (mobile pack). WebView bridges route here from it.

## Adapt to the codebase

Detect the lanes from the roots the pre-flight prints and run the matching rows; **print the lanes you ran** in the output header. Read versions (`targetSdk`, `react-native`, `expo`, `flutter_secure_storage`) from build and lock files before applying a version-gated rule ([`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 50–59). Under **Expo CNG** (`android/` / `ios/` absent or git-ignored), a finding in a generated native file is re-located to `app.json` / `app.config.*` or a config plugin ([`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 3).

| Lane | The truth to read | Secure store (the fix) | Transport config | IPC and link surface | WebView | What ships |
|---|---|---|---|---|---|---|
| **React Native** (bare) | merged manifest; `ios/*/Info.plist`; `babel.config.js` (console strip) | `react-native-keychain` 10, `expo-secure-store`, MMKV 4 with a Keystore-derived key | NSC + manifest; ATS | manifest components and filters; `Linking` | `react-native-webview` props: `originWhitelist`, `onMessage`, file flags | `index.android.bundle` / `main.jsbundle` (Hermes bytecode decompiles: hermes-dec) |
| **Expo** (SDK 53–57) | `app.json` / `app.config.*`, config plugins, the prebuild output | `expo-secure-store` (`keychainAccessible`) | `expo-build-properties` cleartext; ATS via `ios.infoPlist` | `scheme`, `intentFilters`, `associatedDomains` | as RN | `EXPO_PUBLIC_*` inlined; an EAS "secret" read by client code is public |
| **Flutter** | `android/app/src/main/AndroidManifest.xml`, merged manifest under `build/app/intermediates`; `ios/Runner/Info.plist` | `flutter_secure_storage` 11 (v11 removed `encryptedSharedPreferences`) | NSC, ATS; `badCertificateCallback` | manifest; `app_links` / go_router | `webview_flutter` `JavaScriptChannel`, `flutter_inappwebview` handlers | `libapp.so` (`String.fromEnvironment` compiled in); `flutter_dotenv` `.env` shipped as an asset |
| **Android** (Kotlin / Java) | `apkanalyzer manifest print`, `bundletool dump manifest` on the artifact; `res/xml/` | Android Keystore (`KeyGenParameterSpec`) + your own AES-GCM; `security-crypto` is deprecated (advisory only) | NSC `<base-config>`, `<domain-config>`, `<pin-set>` | exported components, `PendingIntent`, `FileProvider` paths | `addJavascriptInterface`, file / content access | dex (jadx), `BuildConfig`, `resValue` |
| **iOS** (Swift / ObjC) | `codesign -d --entitlements :-` on the signed app; `Info.plist`; `INFOPLIST_KEY_*` | Keychain, most restrictive `kSecAttrAccessible…ThisDeviceOnly` that works | ATS (`NSAppTransportSecurity`), `NSPinnedDomains` | `CFBundleURLSchemes`, universal links | `WKScriptMessageHandler`, `loadFileURL` read access | `Info.plist`, `xcconfig`, strings in the binary |

**Tools, as triage input only** — a hit is a candidate for the matching detector, judged by the same read. `mobsfscan --sarif -o mobsfscan.sarif <root>` (LGPL-3.0, invoked, never vendored; native code only). Android lint on the release variant (`./gradlew :app:lintRelease`, with `lintChecks("com.android.security.lint:lint:1.0.4")` for the security lints): ids `TrustAllX509TrustManager`, `BadHostnameVerifier`, `WebViewClientOnReceivedSslError`, `AddJavascriptInterface`, `ExportedReceiver`, `ExportedService`, `UnsafeIntentLaunch`, `MutableImplicitPendingIntent`, `HardcodedDebugMode`, `DataExtractionRules`, `PinSetExpiry`, `MissingBackupPin`. A rule CI already fails at error level is not re-reported ([`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 4).

## Pre-flight greps

Run the block as **one** script from the repo root (shell state does not survive between tool calls). Grep **N** feeds detector **§ N**. Every `rg` names its path — `.`, a root, or `"${ROOTS[@]}"` — because `rg` with no path reads stdin when stdin is a pipe, and returns a silent zero under an agent's shell. `X` drops what never ships in a release build; `NC` drops comment-only lines; `DBG` drops lines behind a debug guard. A root with no manifest, plist or JS is a lane that is absent, not a clean result — say so in the coverage block.

```bash
# Mobile trees: RN/Expo package.json dirs, Flutter pubspec dirs, Android modules, Xcode projects; nested roots folded.
ROOTS=(); while IFS= read -r d; do ROOTS+=("$d"); done < <(
  { rg -l --pcre2 '"(?:react-native|expo)"\s*:' -g 'package.json' -g '!**/{node_modules,test,tests,fixtures,__fixtures__,e2e}/**' .
    rg -l --pcre2 '^\s+sdk:\s*flutter\b' -g 'pubspec.yaml' -g '!**/{.dart_tool,test,tests,fixtures,__fixtures__,e2e}/**' .
    rg --files -g '**/src/main/AndroidManifest.xml' -g '!**/{build,node_modules,.gradle,test,tests,fixtures,__fixtures__,e2e}/**' . | sed 's#/src/main/AndroidManifest\.xml$#/x#'
    rg --files -g '**/*.xcodeproj/project.pbxproj' -g '!**/{Pods,node_modules,DerivedData,test,tests,fixtures,__fixtures__,e2e}/**' . | sed 's#/[^/]*\.xcodeproj/project\.pbxproj$#/x#'
  } | while IFS= read -r f; do dirname "$f"; done | sort -u | awk 'p != "" && index($0, p "/") == 1 { next } { print; p = $0 }')
[ ${#ROOTS[@]} -gt 0 ] || { echo 'no mobile tree (android/, ios/, React Native, Flutter): every class N/A'; exit 0; }
# Never shipped in a release build: deps, build output, tests, debug source sets, generated code, docs.
X=(-g '!**/{node_modules,Pods,build,.gradle,.dart_tool,.expo,DerivedData,dist,vendor,.cxx}/**' -g '!**/src/{test,androidTest,debug}/**'
   -g '!**/{test,tests,__tests__,__mocks__,mocks,fixtures,__fixtures__,testdata,e2e,integration_test,.maestro,docs,ai}/**'
   -g '!**/*{Tests,UITests}/**' -g '!**/*.{test,spec,stories}.*' -g '!**/*_test.dart' -g '!**/*.{g,freezed,mocks}.dart' -g '!**/*.{md,mdx,lock,snap,map}')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(//|/\*|\*(\s|/|$)|#(?!\{)|<!--)')
DBG=(rg -v --pcre2 '__DEV__|kDebugMode|BuildConfig\.DEBUG|#if\s+DEBUG|isDebuggable')
JS=(-g '*.{js,jsx,ts,tsx}'); KT=(-g '*.{kt,java}'); DA=(-g '*.dart'); SW=(-g '*.{swift,m,mm}'); SRC=(-g '*.{js,jsx,ts,tsx,kt,java,dart,swift,m,mm}')
S='(?:token|jwt|bearer|secret|passw(?:or)?d|passcode|\bpin\b|otp|session|cookie|credential|api_?key|private_?key|\bssn\b|iban|card_?number|cvv)'
printf 'root %s\n' "${ROOTS[@]}"
# Per root: the manifests that are the truth (the release merged manifest when a build left one, else every source
# main manifest), the Info.plists, and whether Expo regenerates the native folders (CNG: fixes go to app config).
AM=(); PL=()
for r in "${ROOTS[@]}"; do M=$(rg --files --no-ignore -g '**/intermediates/merged_manifests/release/**/AndroidManifest.xml' "$r" | head -1)
  if [ -n "$M" ]; then AM+=("$M"); echo "manifest $r: merged $M"; else
    while IFS= read -r f; do AM+=("$f"); echo "manifest $r: source $f"; done < <(rg --files -g '**/src/main/AndroidManifest.xml' "${X[@]}" "$r"); fi
  while IFS= read -r f; do PL+=("$f"); done < <(rg --files -g 'Info.plist' "${X[@]}" "$r")
  rg -q --pcre2 '"expo"\s*:' -g 'package.json' "$r" && for d in android ios; do [ -d "$r/$d" ] || echo "cng $r: $d/ absent — expo prebuild generates it; read app.json / app.config.* and config plugins"; done; done
NSC=(); while IFS= read -r f; do NSC+=("$f"); done < <(rg -l '<network-security-config' -g '*.xml' "${X[@]}" "${ROOTS[@]}")

# 1. Sensitive-named values written to plain stores (1a); MMKV with no key or a literal key, and Hive / Realm / Room /
#    sqflite / persisted state in a file that names a credential (1b); external or world-readable locations, file
#    protection off, Keychain classes readable while locked or migrating to a new device (1c); clipboard writes in files
#    with no sensitive-clip flag (1d); sensitive screens in a root with no capture guard anywhere (1e, L2).
rg -n -i --pcre2 "(?:AsyncStorage|\bstorage|mmkv|cache|prefs|preferences)\.(?:setItem|multiSet|mergeItem|set|setString|setStringList)\(\s*['\"\x60][^'\"\x60]*$S|put(?:String|StringSet)\(\s*\"[^\"]*$S|(?:string|byteArray)PreferencesKey\(\s*\"[^\"]*$S|(?:UserDefaults(?:\.standard|\(suiteName:[^)]*\))\.set\(|NSUbiquitousKeyValueStore|@AppStorage\(\s*\")[^\n]*$S" "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l -i --pcre2 "$S" "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -H -n --pcre2 'createMMKV\(\s*\)|createMMKV\(\{(?![^}]*encryptionKey)|new MMKV\(\s*(?:\{(?![^}]*encryptionKey)[^}]*\})?\s*\)|encryptionKey\s*:\s*[\x27"\x60]|Hive\.openBox(?:<[^>]*>)?\((?![^)]*encryptionCipher)|Realm\.Configuration\((?![^)]*encryptionKey)|Room\.databaseBuilder\(|\bopenDatabase\(|persistReducer\(|createJSONStorage\(' "$f"; done | "${NC[@]}"
rg -n --pcre2 'getExternal(?:FilesDir|StorageDirectory|StoragePublicDirectory|CacheDir)\(|MODE_WORLD_(?:READABLE|WRITEABLE)|setReadable\(true,\s*false\)|ExternalDirectoryPath|getExternalStorageDirector(?:y|ies)\(|getDownloadsDirectory\(|NSFileProtectionNone|FileProtectionType\.none|\.noFileProtection|kSecAttrAccessible(?:Always(?:ThisDeviceOnly)?|AfterFirstUnlock)\b|KeychainAccessibility\.first_unlock\b|\bAFTER_FIRST_UNLOCK\b' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l --pcre2 'Clipboard\.(?:setString|setStringAsync|setData)\(|setPrimaryClip\(|UIPasteboard\.general\.(?:string\s*=|setValue|setItems|items\s*=)' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do
  rg -q --pcre2 'IS_SENSITIVE|\.localOnly|expirationDate' "$f" || rg -H -n --pcre2 'Clipboard\.(?:setString|setStringAsync|setData)\(|setPrimaryClip\(|UIPasteboard\.general\.' "$f"; done | "${NC[@]}"
for r in "${ROOTS[@]}"; do SC=$(rg --files "${SRC[@]}" "${X[@]}" "$r" | rg -i --pcre2 '(?:pin|otp|passcode|card|seed|wallet|recovery)[\w-]*(?:screen|view|page|activity|fragment|sheet)\.\w+$' | head -3 | tr '\n' ' ')
  [ -n "$SC" ] && ! rg -q --pcre2 'FLAG_SECURE|SecureFlagPolicy|setRecentsScreenshotEnabled|preventScreenCapture|usePreventScreenCapture|privacySensitive|screenguard|capture-protection|no_screenshot|screen_protector|flutter_windowmanager|ScreenProtector' "${SRC[@]}" "${X[@]}" "$r" && echo "1e $r: sensitive screens ($SC) and no capture protection in the root"; done

# 2. Cleartext and trust. 2a manifest cleartext; network-security-config cleartext (local hosts and <debug-overrides>
#    dropped) and user-installed CAs trusted; 2b ATS opened (inert when a sibling key is set — not printed); 2c code that
#    accepts any certificate or host; 2d http:// literals in shipped code.
[ ${#AM[@]} -eq 0 ] || rg -H -n 'android:usesCleartextTraffic="true"' "${AM[@]}"
rg -n --pcre2 '"?(?:usesCleartextTraffic|NSAllowsArbitraryLoads)"?\s*:\s*true' -g 'app.{json,config.js,config.ts}' "${X[@]}" "${ROOTS[@]}"
[ ${#NSC[@]} -eq 0 ] || perl -0777 -ne 's{<debug-overrides\b.*?</debug-overrides>}{ (my $m = $&) =~ s/[^\n]//g; $m }gse;
  while (/<(base-config|domain-config)\b([^>]*)>(.*?)<\/\1>/gs) { my ($t, $a, $b, $p) = ($1, $2, $3, $-[0]); my $l = 1 + (substr($_, 0, $p) =~ tr/\n//);
    my @d = $b =~ /<domain\b[^>]*>\s*([^<\s]+)/g; my $loc = @d && !grep { !/^(?:localhost|10\.0\.2\.2|127\.0\.0\.1)$/ } @d;
    print "$ARGV:$l: $t cleartextTrafficPermitted=true @d\n" if $a =~ /cleartextTrafficPermitted="true"/ && !$loc;
    print "$ARGV:$l: $t trusts user-installed CAs\n" if $b =~ /<certificates\s+src="user"/ }' "${NSC[@]}"
[ ${#PL[@]} -eq 0 ] || perl -0777 -ne 'next unless /<key>NSAppTransportSecurity<\/key>\s*<dict>(.*?)<\/dict>/s; my ($a, $p) = ($1, $-[1]); my $l = 1 + (substr($_, 0, $p) =~ tr/\n//);
  my $inert = $a =~ /<key>NSAllows(?:ArbitraryLoadsInWebContent|ArbitraryLoadsForMedia|LocalNetworking)<\/key>\s*<true\s*\/>/;
  print "$ARGV:$l: ATS NSAllowsArbitraryLoads=YES (global)\n" if $a =~ /<key>NSAllowsArbitraryLoads<\/key>\s*<true\s*\/>/ && !$inert;
  print "$ARGV:$l: ATS exception allows insecure HTTP\n" if $a =~ /<key>NS(?:ThirdParty)?ExceptionAllowsInsecureHTTPLoads<\/key>\s*<true\s*\/>/;
  print "$ARGV:$l: ATS exception allows TLS 1.0/1.1\n" if $a =~ /MinimumTLSVersion<\/key>\s*<string>TLSv1\.[01]</' "${PL[@]}"
rg -n --pcre2 'checkServerTrusted\([^)]*\)\s*(?::\s*Unit\s*)?(?:=\s*Unit|\{\s*\}|\{\s*return;?\s*\})|HostnameVerifier\s*\{\s*_?\w*\s*,\s*_?\w*\s*->\s*true|\bverify\([^)]*\)\s*(?::\s*Boolean\s*)?=\s*true|ALLOW_ALL_HOSTNAME_VERIFIER|NoopHostnameVerifier|SSLCertificateSocketFactory\.getInsecure|handler\??\.proceed\(\)|DisabledTrustEvaluator|DisabledEvaluator|allHostsMustBeEvaluated:\s*false|badCertificateCallback\s*=\s*\([^)]*\)\s*(?:=>\s*true|\{\s*return\s+true;?\s*\})|ServerTrustAuthResponseAction\.PROCEED|rejectUnauthorized\s*:\s*false' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l --pcre2 '\.useCredential\s*,\s*URLCredential\(trust:' "${SW[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q 'SecTrustEvaluate' "$f" || rg -H -n 'useCredential' "$f"; done
rg -n --pcre2 '["\x27\x60]http://(?!localhost|127\.0\.0\.1|10\.0\.2\.2|0\.0\.0\.0|schemas\.android\.com|www\.w3\.org|www\.apple\.com|ns\.adobe\.com|xmlpull\.org)[\w.-]+' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}" | "${DBG[@]}"

# 3. IPC. 3a components exported with no permission (launcher activities dropped — precedent § 46); 3b FileProvider and
#    grant paths over the root; 3c a received Intent relaunched (intent redirection); 3d PendingIntent with no IMMUTABLE
#    flag on the line; 3e runtime receivers registered with no exported flag.
[ ${#AM[@]} -eq 0 ] || perl -0777 -ne 'while (/<(activity|activity-alias|service|receiver|provider)\b([^>]*?)(?:\/>|>(.*?)<\/\1>)/gs) { my ($k, $a, $b, $p) = ($1, $2, $3 // "", $-[0]);
    next unless $a =~ /android:exported="true"/; next if $a =~ /android:(?:read|write)?[pP]ermission="/; next if $b =~ /category\.LAUNCHER/;
    my $l = 1 + (substr($_, 0, $p) =~ tr/\n//); my ($n) = $a =~ /android:name="([^"]+)"/; my @act = $b =~ /<action\s+android:name="([^"]+)"/g;
    print "$ARGV:$l: $k $n exported, no permission", (@act ? " (actions: @act)" : ""), ($b =~ /category\.BROWSABLE/ ? " — link entry, see 4" : ""), "\n" }' "${AM[@]}"
rg -n --pcre2 '<root-path\b|<(?:files|cache|external|external-files|external-cache|external-media)-path\b[^>]*\bpath="(?:\.|/|)"|<grant-uri-permission\b[^>]*android:path(?:Prefix)?="/"' -g '*.xml' "${X[@]}" "${ROOTS[@]}"
rg -l --pcre2 'getParcelableExtra|getParcelable<Intent>|IntentCompat\.getParcelableExtra|Intent\.parseUri\(' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q --pcre2 '\b(?:startActivity|startActivities|startService|startForegroundService|sendBroadcast|setResult|bindService)\(' "$f" && rg -H -n --pcre2 'getParcelableExtra|getParcelable<Intent>|Intent\.parseUri\(|\b(?:startActivity|startService|sendBroadcast|setResult)\(' "$f"; done | "${NC[@]}"
rg -n --pcre2 'PendingIntent\.get(?:Activity|Activities|Service|Broadcast|ForegroundService)\((?![^;\n]*FLAG_IMMUTABLE)|registerReceiver\((?![^;\n]*RECEIVER_(?:NOT_)?EXPORTED)' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"

# 4. Links. 4a https/http VIEW filters without autoVerify, and every custom scheme the app claims (manifest, Info.plist,
#    Expo config); 4b a custom scheme carrying a credential or an auth / reset / payment return; 4c link entry points.
[ ${#AM[@]} -eq 0 ] || perl -0777 -ne 'while (/<intent-filter\b([^>]*)>(.*?)<\/intent-filter>/gs) { my ($a, $b, $p) = ($1, $2, $-[0]); next unless $b =~ /action\.VIEW/ && $b =~ /category\.BROWSABLE/;
    my $l = 1 + (substr($_, 0, $p) =~ tr/\n//); my @s = $b =~ /android:scheme="([^"]+)"/g;
    print "$ARGV:$l: app link not verified (no autoVerify) @s\n" if grep({ /^https?$/ } @s) && $a !~ /autoVerify="true"/;
    my @c = grep { !/^https?$/ } @s; print "$ARGV:$l: custom scheme @c\n" if @c }' "${AM[@]}"
[ ${#PL[@]} -eq 0 ] || perl -0777 -ne 'while (/<key>CFBundleURLSchemes<\/key>\s*<array>(.*?)<\/array>/gs) { my ($b, $p) = ($1, $-[0]); my $l = 1 + (substr($_, 0, $p) =~ tr/\n//); my @s = $b =~ /<string>([^<]+)<\/string>/g; print "$ARGV:$l: custom scheme @s\n" }' "${PL[@]}"
rg -n --pcre2 '"scheme"\s*:\s*(?:"[^"]+"|\[)' -g 'app.{json,config.js,config.ts}' "${X[@]}" "${ROOTS[@]}"
SCH=$( { [ ${#AM[@]} -eq 0 ] || rg -o --no-filename --pcre2 'android:scheme="\K(?!https?")[^"$]+' "${AM[@]}"; [ ${#PL[@]} -eq 0 ] || perl -0777 -ne 'while (/<key>CFBundleURLSchemes<\/key>\s*<array>(.*?)<\/array>/gs) { print "$_\n" for $1 =~ /<string>([^<\$]+)<\/string>/g }' "${PL[@]}"
  rg -o --no-filename --pcre2 '"scheme"\s*:\s*"\K[^"]+' -g 'app.{json,config.js,config.ts}' "${X[@]}" "${ROOTS[@]}"; } | sort -u | paste -sd '|' -)
[ -z "$SCH" ] || rg -n -i --pcre2 "\b(?:$SCH)://[^\s'\"\x60]*(?:auth|oauth|callback|redirect|login|signin|token|code|reset|verify|magic|pay|checkout|session)" "${SRC[@]}" -g '*.{json,plist,xml}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '\bgetInitialURL\(|Linking\.addEventListener\(\s*["\x27]url|\buseURL\(|\bgetInitialLink\(|\buriLinkStream\b|\.onOpenURL\b|openURLContexts|application\(_[^)]*open url:|\bonNewIntent\(|getIntent\(\)\.getData\(|\bintent\??\.data\b' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"

# 5. WebViews. 5a native bridges, file / universal access, debugging, a URL loaded from an Intent; 5b RN WebViews that
#    expose a bridge (onMessage / injectedJavaScript) — every risky prop in those files; 5c WKScriptMessageHandlers with
#    no frame-origin check.
rg -n --pcre2 'addJavascriptInterface\(|setAllowFileAccess(?:FromFileURLs)?\(\s*true|setAllowUniversalAccessFromFileURLs\(\s*true|allow(?:FileAccess|FileAccessFromFileURLs|UniversalAccessFromFileURLs|ContentAccess)\s*=\s*true|setAllowContentAccess\(\s*true|setWebContentsDebuggingEnabled\(\s*true|loadUrl\([^)\n]*(?:getStringExtra|getQueryParameter|intent\b|\.data\b)|isInspectable\s*=\s*true|\bUIWebView\b|addJavaScriptChannel\(|JavascriptChannel\(|addJavaScriptHandler\(|allow(?:FileAccessFromFileURLs|UniversalAccessFromFileURLs):\s*true' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l --pcre2 'react-native-webview' "${JS[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q --pcre2 'onMessage=|injectedJavaScript' "$f" || continue
  rg -q 'nativeEvent\.url' "$f" || echo "$f: onMessage acts with no nativeEvent.url check"
  rg -H -n --pcre2 'originWhitelist=\{\[\s*["\x27]\*["\x27]\s*\]\}|allow(?:FileAccess|FileAccessFromFileURLs|UniversalAccessFromFileURLs)=\{true\}|mixedContentMode=["\x27](?:always|compatibility)|webviewDebuggingEnabled=\{true\}|setSupportMultipleWindows=\{false\}|injectedJavaScript(?:BeforeContentLoaded)?=|onMessage=|source=\{\{\s*uri:\s*(?![\x27"\x60]https://)' "$f"; done
rg -l --pcre2 'WKScriptMessageHandler|didReceive message:|addScriptMessageHandler|\.add\(self,\s*name:' "${SW[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q --pcre2 'frameInfo|securityOrigin' "$f" || rg -H -n --pcre2 'didReceive message:' "$f"; done

# 6. Release config. 6a debuggable, and backup left on with no extraction rules (a rules file is printed to read);
#    6b release signed with the debug key, debuggable or unminified release, literal store passwords, committed keys;
#    6c get-task-allow in an entitlements file; 6d credentials in device logs and wire loggers outside a debug guard.
[ ${#AM[@]} -eq 0 ] || perl -0777 -ne 'next unless /<application\b([^>]*)>/s; my ($a, $p) = ($1, $-[0]); my $l = 1 + (substr($_, 0, $p) =~ tr/\n//);
  print "$ARGV:$l: android:debuggable=true\n" if $a =~ /android:debuggable="true"/; next if $a =~ /android:allowBackup="false"/;
  my ($r) = $a =~ /android:(?:dataExtractionRules|fullBackupContent)="([^"]+)"/;
  print "$ARGV:$l: backup ", ($a =~ /allowBackup="true"/ ? "on" : "on by default"), ($r ? " — rules $r: read for sharedpref/database/file includes" : ", no extraction rules — the whole sandbox is backed up"), "\n"' "${AM[@]}"
rg -n -U --pcre2 '\brelease\s*\{[^}]*?\b(?:signingConfig\s*=?\s*signingConfigs\.(?:getByName\(\s*"debug"\s*\)|debug)\b|isDebuggable\s*=\s*true|debuggable\s+true|minifyEnabled\s+(?:false|enableProguardInReleaseBuilds)|isMinifyEnabled\s*=\s*false)' -g '*.gradle*' "${X[@]}" "${ROOTS[@]}"
rg -n --pcre2 '(?:storePassword|keyPassword)\s*=?\s*\(?\s*["\x27](?!android["\x27])[^"\x27$]+["\x27]|^\s*(?:\w+_)?(?:STORE|KEY)_PASSWORD\s*=\s*\S' -g '*.gradle*' -g 'gradle.properties' -g 'local.properties' "${X[@]}" "${ROOTS[@]}"
rg --files -g '*.{jks,keystore,p12,p8,mobileprovision}' -g '!**/debug.keystore' "${X[@]}" "${ROOTS[@]}" | sed 's/$/: key material in the tree (secret-scan-method owns rotation)/'
rg -n -U --pcre2 '<key>get-task-allow</key>\s*<true\s*/>' -g '*.entitlements' "${X[@]}" "${ROOTS[@]}"
{ rg -n -i --pcre2 "console\.(?:log|info|debug|warn|error)\([^\n]*$S" "${JS[@]}" "${X[@]}" "${ROOTS[@]}"
  rg -n -i --pcre2 "\b(?:Log\.[vdiwe]|Timber\.[vdiwe]|println)\([^\n]*$S" "${KT[@]}" "${X[@]}" "${ROOTS[@]}"
  rg -n -i --pcre2 "\b(?:print|NSLog|debugPrint|os_log)\([^\n]*$S|privacy:\s*\.public" "${SW[@]}" "${X[@]}" "${ROOTS[@]}"
  rg -n -i --pcre2 "\b(?:print|debugPrint|log)\([^\n]*$S" "${DA[@]}" "${X[@]}" "${ROOTS[@]}"
  rg -n --pcre2 'HttpLoggingInterceptor\.Level\.(?:BODY|HEADERS)|LogInterceptor\([^)\n]*(?:requestHeader|requestBody|responseBody):\s*true|\bredux-logger\b|\breactotron\b' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}"; } | "${NC[@]}" | "${DBG[@]}"
for r in "${ROOTS[@]}"; do rg -q --pcre2 '"react-native"\s*:' -g 'package.json' "$r" || continue
  rg -q 'transform-remove-console' -g 'babel.config.*' -g '.babelrc*' "$r" || echo "6d $r: console.* reaches the device log in release (no transform-remove-console)"; done

# 7. Secrets and crypto. 7a key shapes in shipped source and config (Firebase config files listed apart — precedent
#    § 44); 7b secret-named build-time values compiled into the app, and .env shipped as an asset; 7c broken crypto and
#    literal keys; 7d a non-cryptographic RNG in a file that mints OTPs, nonces, salts or tokens; 7e the shipped artifact.
rg -n --hidden --no-ignore --pcre2 '\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_\-]{35}|xox[baprs]-[\w-]{10,}|ghp_[0-9A-Za-z]{30,}|sk-(?:ant|proj)-[\w-]{20,})|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----' -g '!google-services.json' -g '!GoogleService-Info.plist' -g '!.git/**' "${X[@]}" "${ROOTS[@]}"
rg --files --hidden -g 'google-services.json' -g 'GoogleService-Info.plist' "${X[@]}" "${ROOTS[@]}" | sed 's/$/: Firebase config — a project identifier, not a secret (precedent § 44); check API-key restrictions/'
rg -n --pcre2 'buildConfigField\s*\(?\s*["\x27]String["\x27]\s*,\s*["\x27]\w*(?:SECRET|PRIVATE|PASSWORD|TOKEN|API_?KEY)|String\.fromEnvironment\(\s*["\x27]\w*(?:SECRET|PRIVATE|PASSWORD|TOKEN|KEY)|\bConfig\.\w*(?:SECRET|PRIVATE|PASSWORD)\w*|EXPO_PUBLIC_\w*(?:SECRET|PRIVATE|SERVICE_?ROLE|ADMIN|PASSWORD|SIGNING|WEBHOOK)|<key>\w*(?:Secret|SECRET|Password|PASSWORD|PrivateKey)\w*</key>' "${SRC[@]}" -g '*.{plist,xcconfig,gradle,kts,json}' -g '.env*' --hidden "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '^\s*-\s*(?:\./)?(?:[\w/]*/)?\.env[\w.]*\s*$' -g 'pubspec.yaml' "${ROOTS[@]}"
rg -n --pcre2 'Cipher\.getInstance\(\s*"(?:AES|DES|DESede|RC4|Blowfish|[^"]*/ECB/[^"]*)"|SecretKeySpec\(\s*"|IvParameterSpec\(\s*(?:"|byteArrayOf|ByteArray\(\d)|MessageDigest\.getInstance\(\s*"(?:MD5|SHA-?1)"|\bInsecure\.(?:MD5|SHA1)\b|kCCOptionECBMode|\bCC_MD5\(|AESMode\.ecb|IV\.fromLength\(' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l -i --pcre2 'otp|nonce|salt|secret|passw|token|verifier|\biv\b|\bpin\b' "${SRC[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -H -n --pcre2 'Math\.random\(\)|\bnew Random\(|java\.util\.Random\b|kotlin\.random\.Random\b|(?<![.\w])Random\.next\w+\(|(?<![.\w])Random\(\)(?!\.secure)|(?<![.\w])Random\(\d+\)' "$f"; done | "${NC[@]}"
rg --files --no-ignore -g '*.{apk,aab,ipa}' -g 'index.android.bundle' -g 'main.jsbundle' -g 'libapp.so' -g '!**/node_modules/**' "${ROOTS[@]}" | head -5 | while IFS= read -r a; do
  case "$a" in *.apk|*.aab|*.ipa) H=$(unzip -p "$a" 2>/dev/null | strings -n 12 | rg -c --pcre2 'sk_live_|AKIA[0-9A-Z]{16}|PRIVATE KEY-----|EXPO_PUBLIC_\w*SECRET') ;; *) H=$(strings -n 12 "$a" | rg -c --pcre2 'sk_live_|AKIA[0-9A-Z]{16}|PRIVATE KEY-----|EXPO_PUBLIC_\w*SECRET') ;; esac
  echo "7e $a: ${H:-0} secret-shaped strings in the artifact"; done

# 8. Pinning and resilience posture (inventory, not findings). 8a every pin-set (pins, expiry); 8b pinning code per root;
#    8c root / jailbreak / integrity checks per root; 8d a declared MASVS L2 posture anywhere in the repo's docs.
[ ${#NSC[@]} -eq 0 ] || TODAY=$(date +%F) perl -0777 -ne 'while (/<pin-set\b([^>]*)>(.*?)<\/pin-set>/gs) { my ($a, $b, $p) = ($1, $2, $-[0]); my $l = 1 + (substr($_, 0, $p) =~ tr/\n//);
    my $n = () = $b =~ /<pin\b/g; my ($e) = $a =~ /expiration="([^"]+)"/;
    print "$ARGV:$l: pin-set $n pin(s), ", (!$e ? "no expiration" : $e lt $ENV{TODAY} ? "EXPIRED $e — pinning is off" : "expires $e"), ($n < 2 ? ", no backup pin" : ""), "\n" }' "${NSC[@]}"
for r in "${ROOTS[@]}"; do P=$(rg -l --pcre2 'CertificatePinner|<pin-set|NSPinnedDomains|TrustKit|react-native-ssl-(?:public-key-)?pinning|SecurityContext\(\s*withTrustedRoots:\s*false|setTrustedCertificatesBytes|http_certificate_pinning|PinnedCertificatesTrustEvaluator|PublicKeysTrustEvaluator' "${SRC[@]}" -g '*.{xml,plist,json,yaml}' "${X[@]}" "$r" | head -3 | tr '\n' ' ')
  I=$(rg -l --pcre2 'RootBeer|JailMonkey|jail-monkey|flutter_jailbreak_detection|safe_device|IOSSecuritySuite|freerasp|IntegrityManager|PlayIntegrity|DCAppAttestService|DCDevice\b' "${SRC[@]}" -g '{package.json,pubspec.yaml,Podfile}' -g '*.{gradle,kts}' "${X[@]}" "$r" | head -3 | tr '\n' ' ')
  echo "8 $r: pinning ${P:-none} · integrity checks ${I:-none}"; done
L2=$(rg -l -i --pcre2 '\bMASVS\b|\bMASWE\b|\bMAS-?L2\b|\bPCI[ -]DSS\b|\bHIPAA\b' -g '{ai,docs}/**/*.md' -g 'README*' -g 'SECURITY*' -g '.claude/rules/*.md' -g '!**/node_modules/**' . | head -3 | tr '\n' ' ')
if [ -n "$L2" ]; then echo "8d posture: read ${L2}for a declared L2 profile"; else echo "8d posture: none declared — L1, so L2-only classes are Hardening"; fi
```

A finding is a grep hit **plus** a read of the enclosing component or config **plus** the attacker's path to it. Two blind spots are structural:
- **Wrappers hide the key.** `store.write(key, value)` in a key-value wrapper never shows the literal key to grep 1a; list the wrapper's callers once and read which keys reach the plaintext backend.
- **Source manifests lie.** A library injects exported components the source never declares, and Expo plugins write the real manifest at prebuild. With no merged manifest the pre-flight reads source manifests and says so (`manifest <root>: source …`); every § 3 / § 4 / § 6 row is then `[report-flagged]` until `apkanalyzer manifest print app.apk` or the merged manifest confirms it.

## The 3,000-hit path

1. **Intersect** with the release build: drop debug, test, sample and gallery modules with one read that shows nothing in the release variant includes them (`settings.gradle*`, the scheme's targets, the app's imports); name each in the coverage block.
2. **Group** by root cause: one storage wrapper, one HTTP client factory, one base WebView, one manifest placeholder is one group however many call sites use it.
3. **Rank** by attacker capability × asset: another-app and network classes over a credential first (§ 2, § 3, § 4, § 5); shipped secrets (§ 7); then L2 and posture rows.
4. **Read the top 30 groups fully**; prove the top 5 on the artifact (tier A) or a device (tier D).
5. **Ledger the rest** as `Live, unreviewed (k groups / n hits)` with the command that continues them. Never `checked`.

## Scans for

Default severity assumes a release build, a credential or user data at stake, and the attacker capability the row names. Drop one step when the asset is non-sensitive; never drop an L1 class to "info".

| § | Class | MASVS · MASWE | CWE | Attacker | Default |
|---|---|---|---|---|---|
| 1 | Credential in plain storage, external or migrating stores; clipboard; sensitive screens | STORAGE · 0001 (L2), 0002, 0030, 0038 (L2) | CWE-312, CWE-922 | device / backup holder | Hardening (L2) · high outside private storage |
| 2 | Cleartext traffic, user CAs trusted, broken certificate or hostname validation | NETWORK · 0026, 0027 | CWE-319, CWE-295 | network | critical (trust-all) · high (cleartext credential) · medium (ATS / NSC opened) |
| 3 | Exported component without permission, intent redirection, mutable implicit `PendingIntent`, FileProvider over-scope | PLATFORM · 0032 | CWE-926, CWE-927 | another app | high |
| 4 | Deep-link and App Link hijack: unverified https links, custom schemes carrying credentials or returns | PLATFORM · 0029 | CWE-939 | another app / a web page | high |
| 5 | WebView: native bridge reachable by untrusted content, file / universal access, debugging | PLATFORM · 0033–0035 | CWE-749 | a web page | critical (bridge to a token or payment) |
| 6 | Release config: debuggable, debug-signed, backup on with no rules, `get-task-allow`, credentials in device logs | STORAGE · 0005, 0006 · R 0061, 0063 | CWE-489, CWE-530, CWE-532 | device / backup holder | high |
| 7 | Server secrets and keys shipped in the app; broken crypto; non-cryptographic RNG for secrets | STORAGE · 0004 · CRYPTO | CWE-798, CWE-321, CWE-327, CWE-338 | APK / IPA holder | critical (server secret) · high (literal key, ECB) |
| 8 | Pinning and resilience posture — inventory, findings only on misconfiguration | NETWORK-2 · 0028 (L2) · RESILIENCE | CWE-295 | network | Hardening; medium for an expired pin set once L2 |

### 1. Credential in plain storage, external locations, clipboard, sensitive screens

```
BAD:   AsyncStorage.setItem('refreshToken', t)   prefs.setString('access_token', t)   UserDefaults.standard.set(token, forKey: "authToken")
       createMMKV({ id: 'auth' })   kSecAttrAccessibleAlways   getExternalFilesDir(null) + token file   Clipboard.setString(otp)
GOOD:  SecureStore.setItemAsync('refreshToken', t, { keychainAccessible: WHEN_UNLOCKED_THIS_DEVICE_ONLY })
       FlutterSecureStorage().write(...)   Keychain kSecAttrAccessibleWhenUnlockedThisDeviceOnly   MMKV keyed from the Keystore
       ClipDescription.EXTRA_IS_SENSITIVE (API 33+) · UIPasteboard setItems(_:options: [.localOnly: true, .expirationDate: …])
```
**Read the code:** Is the value sensitive by **value**, not just by name (`tokenExpiryShown` is not a token)? Where does the store live — app-private (L2), or external storage, a world-readable mode, a backup-included file, iCloud key-value sync (`NSUbiquitousKeyValueStore`), an App Group (L1)? Is MMKV keyed, and does the key come from the Keystore / Keychain or from a literal in the bundle (§ 7)? Does redux-persist / zustand `persist` include the auth slice (`whitelist` / `partialize`)? A Keychain class of `AfterFirstUnlock` (not `ThisDeviceOnly`) migrates to a new device through encrypted backups — is that a recorded decision (handset replacement) or an accident? Is the clipboard value an OTP, card number or seed phrase? **Finding when** a credential reaches a non-private location (L1), or app-private plaintext on a declared-L2 project. **Severity:** high outside private storage; Hardening for private storage on L1 (medium / high once L2); `AfterFirstUnlock` without `ThisDeviceOnly` for a token medium on L2, Hardening with a recorded reason; EncryptedSharedPreferences is a deprecation advisory, never a vuln ([`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 42). Grep 1e (no capture guard on PIN / card / seed screens) is L2 — Hardening on L1, and never on non-sensitive screens (§ 47). **Proof:** A — [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M5 (read the seeded canary token out of `shared_prefs/`, `RKStorage`, the simulator container) and M4 when the store is backup-included.

### 2. Cleartext traffic and broken certificate validation

```
BAD:   android:usesCleartextTraffic="true"   <base-config> <certificates src="user"/>   NSAllowsArbitraryLoads = YES
       override fun checkServerTrusted(...) {}   handler?.proceed()   badCertificateCallback = (c, h, p) => true
       completionHandler(.useCredential, URLCredential(trust: trust))   // no SecTrustEvaluateWithError first
GOOD:  no cleartext outside a debug-only config scoped to 10.0.2.2 / localhost; user CAs only inside <debug-overrides>
       a TrustManager that DELEGATES to the platform default; SecTrustEvaluateWithError before .useCredential
       per-domain NSExceptionDomains with a reason, never the global switch
```
**Read the code:** Is the trust-all path reachable in release, or behind a staging flavor the release variant excludes? Does a cleartext flag carry real traffic (an `http://` literal from grep 2d, an `http` base URL in config)? Is `NSAllowsArbitraryLoads` inert — `…InWebContent`, `…ForMedia` or `NSAllowsLocalNetworking` present (iOS 10+, precedent § 45)? Does the NSC's cleartext `<domain-config>` cover only local hosts? **Finding when** release traffic can be read or impersonated by a network attacker. **Severity:** critical for trust-all / hostname-allow-all / SSL-error proceed / `badCertificateCallback => true` (MASWE-0027, L1); high for cleartext carrying a credential; medium for a global ATS or NSC opening with no observed HTTP use (and an App Review justification, `store-compliance-scan`'s); medium for user CAs in `<base-config>`. **Proof:** D — [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M6: mitmproxy with **no** CA installed; a correct app fails the handshake, a trust-all app's flows appear.

### 3. Exported components, intent redirection, PendingIntent, FileProvider

```
BAD:   <receiver android:name=".TokenReceiver" android:exported="true"> (no permission; onReceive writes the session)
       val next = intent.getParcelableExtra<Intent>("next"); startActivity(next)   // relaunches the caller's Intent
       PendingIntent.getActivity(ctx, 0, Intent("com.acme.OPEN"), FLAG_MUTABLE)    <root-path name="r" path="."/>
GOOD:  android:exported="false", or a permission with android:protectionLevel="signature"
       validate the nested Intent: resolveActivity() inside the app's own package and not exported, strip URI grant flags
       FLAG_IMMUTABLE on an explicit Intent; FileProvider paths scoped to one directory
```
**Read the code:** What does the component do with its input — write auth state, start another component, return a result, open a file? Is the permission `signature`-level (a `normal` permission any app can request is no control)? For a receiver filtering a protected system broadcast (`BOOT_COMPLETED`), does `onReceive` check the action (an explicit Intent from another app bypasses the filter)? Is the mutable `PendingIntent` explicit (RemoteInput, bubbles — legitimate, § 46)? **Finding when** another app on the device can drive an auth-sensitive action, reach a non-exported component through redirection, or read files through a provider. **Severity:** high; critical when it yields the session or arbitrary file read. The launcher activity is required and is not printed; StrandHogg is moot at `minSdk` ≥ 30 (§ 46). **Proof:** D — [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M1 (`am start` / `am broadcast` / `content query` with a canary, and a test APK that nests an Intent).

### 4. Deep-link and App Link hijack

```
BAD:   <intent-filter> VIEW + BROWSABLE + https, no android:autoVerify="true"     // any app can claim the link; a chooser appears
       onLoginLink('acmeshop://login?token=…') → signInWithToken(token)            // any app can register acmeshop://
GOOD:  autoVerify="true" + assetlinks.json listing the Play App Signing SHA-256; iOS applinks: + an AASA served raw over HTTPS
       magic-link, reset and payment returns on verified https links; OAuth on a custom scheme only with PKCE (RFC 8252)
```
**Read the code:** What does the link carry — a one-time sign-in token, a reset token, an OAuth `code`, a payment result? Is the custom scheme unique to this app, or a word another app can claim? For OAuth, is PKCE on (an intercepted `code` is useless without the verifier — then Hardening)? Does a link reach a privileged screen without a session, a WebView (`source={{ uri: params.url }}`) or `openURL` with a foreign host? **Finding when** another app or a web page can receive a credential meant for this app, or drive an authenticated action through a link. **Severity:** high for credential-bearing links on a custom or unverified scheme; medium for an unverified https filter whose links carry no credential; a marketing "open the app" scheme is not a finding (§ 27). **Proof:** D — [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M2 (a second test app claiming the scheme receives the canary token; `pm get-app-links` shows the domain unverified).

### 5. WebView bridges and file access

```
BAD:   view.addJavascriptInterface(NotesBridge(token), "NotesApp"); view.loadUrl(intent.getStringExtra("url"))
       <WebView originWhitelist={['*']} source={{ uri: checkoutUrl }} onMessage={e => confirmPayment(JSON.parse(e.nativeEvent.data))} />
       addJavaScriptChannel('Wallet', onMessageReceived: …runJavaScript("receiveToken('$token')"))   didReceive message: → Payments.confirm
GOOD:  load only bundled HTML or an allow-listed first-party origin, enforced in shouldOverrideUrlLoading / onShouldStartLoadWithRequest;
       check nativeEvent.url / message.frameInfo.securityOrigin before acting; never pass a token into page JS
```
**Read the code:** Which origins can the WebView load — a fixed first-party URL, or one from a link, an Intent extra or an API field? What can the bridge do — read a token, pay, open a URL, read files? Does the handler check the origin (`nativeEvent.url`, `frameInfo.securityOrigin`, `isMainFrame`)? Are `allowFileAccessFromFileURLs` / `allowUniversalAccessFromFileURLs` on while untrusted content can load? Is `setSupportMultipleWindows={false}` set (re-exposes CVE-2020-6506)? **Finding when** content an attacker controls can call a native action or read local files. **Severity:** critical for a bridge exposing a token or a payment; high for file or universal access with untrusted content; medium for debugging enabled in release. A static `source={{ html }}` WebView with no bridge is not printed. **Proof:** D — [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M3 (a page from a host the test controls calls the bridge with a nonce).

### 6. Release config, backup and device logs

```
BAD:   release { signingConfig signingConfigs.debug }   android:debuggable="true"   <application> with no allowBackup / dataExtractionRules
       <key>get-task-allow</key><true/> in the App Store entitlements   console.log('session saved', accessToken) with no console strip
       Log.d(TAG, "token=$token")   print("refresh token stored: \(token)")   HttpLoggingInterceptor.Level.BODY outside a debug guard
GOOD:  a release signingConfig read from CI secrets; debuggable left to the build type, never set in the manifest
       android:allowBackup="false", or dataExtractionRules (API 31+) and fullBackupContent (≤ 30) that exclude the token and database files
       get-task-allow only in the Debug configuration's entitlements; transform-remove-console in release; wire loggers at NONE unless BuildConfig.DEBUG
```
**Read the code:** Is the line in the **release** variant — the merged manifest under `intermediates/merged_manifests/release`, the `release` build type, the entitlements file the Release configuration names (`CODE_SIGN_ENTITLEMENTS` per configuration in the `.pbxproj`, the reading of [`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 29)? Is the RN template's `debug.keystore` used by `debug` only, or does `release` point at it (§ 48)? With backup on, what does the sandbox hold — a token in prefs or a database (§ 1), or only caches? Does a rules file exclude those paths in **both** `<cloud-backup>` and `<device-transfer>`, and in `fullBackupContent` for API ≤ 30? Is a log call compiled out — `transform-remove-console` in `babel.config.*`, R8 `-assumenosideeffects` on `android.util.Log`, a `kReleaseMode` / `#if DEBUG` guard? Does the logged value carry the secret, or only its name (`'token refreshed'`)? Is a `privacy: .public` interpolation a credential? **Finding when** a release build is debuggable or debug-signed, its backup carries a credential, the distribution entitlements allow a debugger to attach, or a credential reaches the device log in release. **Severity:** high for a debuggable or debug-signed release (MASWE-0061 / 0063 — the R exception) and for a credential or a `BODY` wire log in release logs (MASWE-0005, L1); high for backup on with a credential in the backed-up sandbox (MASWE-0006, L1), medium when it holds user data but no credential; medium for `get-task-allow` true in the distribution entitlements (the provisioning profile can override the file — prove it on the signed app); a literal store password beside a committed keystore is `secret-scan-method`'s. Minify off is the R profile: an § 8 inventory line, and `app-size-scan`'s shrinking row when the mobile pack is installed. **Proof:** A — [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M8 (`apkanalyzer manifest debuggable`, `apksigner verify --print-certs`, `codesign -d --entitlements :-` on the signed artifact); D — M4 (a seeded canary survives backup → reinstall → restore) and M5 (the canary in `adb logcat --pid` or the simulator log stream).

### 7. Server secrets shipped in the app, broken crypto, weak randomness

```
BAD:   EXPO_PUBLIC_STRIPE_SECRET_KEY=sk_live_…   GenerativeModel(apiKey: 'AIza…')   String.fromEnvironment('PAYMENT_SECRET')
       pubspec.yaml assets: [.env]   buildConfigField "String", "ADMIN_TOKEN", "\"…\""   SecretKeySpec("0123456789abcdef".toByteArray(), "AES")
       Cipher.getInstance("AES/ECB/PKCS5Padding")   java.util.Random().nextInt(1_000_000) for an OTP   Math.random() for a nonce
GOOD:  the secret stays on a server the app calls with the user's session; the client holds publishable keys and ids only
       AES-GCM under a Keystore / Keychain key with a fresh random IV
       SecureRandom · crypto.getRandomValues / expo-crypto · Random.secure() · SecRandomCopyBytes
```
**Read the code:** Is the value a **server** secret — Stripe `sk_live_`, an AWS key pair, a database URL with a password, a signing key, a Gemini Developer API key — or a publishable value or project identifier (Firebase `AIza…`, `pk_live_`, a Supabase anon key behind RLS, a Sentry DSN, an OAuth client id; [`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 44)? Does client code read it? Any `EXPO_PUBLIC_*` read by dot access, any `--dart-define` read by `String.fromEnvironment`, and any `.env` declared as a Flutter asset is in the artifact, whatever its EAS visibility. For crypto: what does the cipher protect, and where does the key come from — a literal (CWE-321) or the Keystore / Keychain? Is the RNG output a secret (OTP, nonce, salt, token, IV) or a UI value (a shuffle, a retry jitter)? **Finding when** a server secret is in the shipped app, data is encrypted under a key the app ships, ECB or a static IV protects a secret, or a secret comes from a non-cryptographic RNG. **Severity:** critical for a server secret in the bundle or binary (MASWE-0004, CWE-798); high for a literal key or ECB over a credential (CWE-321, CWE-327); high for an OTP, nonce or token from `Math.random` / `java.util.Random` / Dart `Random()` (CWE-338), medium when the value only de-duplicates requests. A Firebase config file is a line to check API-key restrictions and App Check, never a secret. One finding per key: the same `sk_live_` printed by greps 7a and 7b, by `secret-scan-method` and by `xss-scan` § 12a is one row (§ Boundaries). 7e reads a built artifact when one is in the tree; none → the bundle proof is `blocked`, not `checked`. **Proof:** A — [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M7: build the release artifact with a canary in the variable (`EXPO_PUBLIC_…=canary-<uuid>`) and find the canary in the bundle (`strings`; `hbc-decompiler` for a Hermes bundle; `libapp.so` for Flutter). Rotating a real key that already shipped is `secret-scan-method`'s.

### 8. Pinning and resilience posture — inventory

```
BAD:   <pin-set expiration="2025-06-30"> with one pin, past its date      // fail-open: pinning is silently off
       a pin set or CertificatePinner with no backup pin                    // a key rotation breaks every installed client
GOOD:  no pinning on an L1 app (Android's guidance discourages it); or ≥ 2 SPKI pins, one outside the current chain,
       an expiration a release cycle ahead with an alert before it; NSPinnedDomains / TrustKit / CertificatePinner with a backup pin
```
**Read the code:** Which hosts does 8b's pinning cover, per root? Is a `<pin-set>` expired — then pinning is **absent** and is reported as absent ([`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 43)? Does every pin set carry a backup pin? Does the project declare L2 — grep 8d prints the docs that might; a passing mention of MASVS is not a declared posture. Root / jailbreak / integrity checks (8c) and obfuscation are inventory: never a finding by their absence, and a check that only warns is not a control a § 1–7 claim may lean on. **Finding when** — only on misconfiguration: a declared-L2 project with no pinning on its auth host, an expired pin set the team believes active (a doc or comment says the app pins), or a pin set with no backup pin. **Severity:** Hardening on L1; medium on a declared L2; never above medium. **Proof:** A / D — [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M6: compare each pin with the server's SPKI hash; on the owner's test device, a debug build whose `<debug-overrides>` trusts the proxy CA without `overridePins` must fail the handshake to a pinned host — flows appearing mean the pin is not enforced.

## Output

```
mobile-security-scan — lanes: react-native (rn-app, source manifest), android (android-native/app, merged release manifest),
                       flutter (flutter_app), ios (ios-app); expo absent · posture: L1 (no L2 declaration found)

Findings: 3

1. flutter_app/lib/net/http_client.dart:4                              [report-with-fix]  critical
   §2 trust-all — badCertificateCallback returns true for every certificate; buildClient() is the app's only
   HTTP client and is not behind kDebugMode. Attacker: a network attacker with no trusted CA. Fix: delete the
   callback; trust the staging CA through a debug-only SecurityContext. Proof D: § Mobile M6 (no CA installed,
   the canary request appears in the proxy). MASVS-NETWORK · MASWE-0027 · CWE-295

2. rn-app/android/app/src/main/AndroidManifest.xml:27                  [report-with-fix]  high
   §3 exported receiver — .sec.TokenReceiver is exported with no permission and writes the "session" extra
   into prefs as session_token (TokenReceiver.kt:9–10). Attacker: another app on the device sets the user's
   session. Fix: android:exported="false" (nothing outside the app sends it). Proof D: § Mobile M1 — a
   broadcast to the debug build carries a canary, run-as reads it back. MASVS-PLATFORM · MASWE-0032 · CWE-926

3. rn-app/src/auth/signInLink.ts:5                                     [report-flagged]  high
   §4 credential on a custom scheme — acmeshop://login?token=… signs the user in; any app can register
   acmeshop://. Unproven link: whether sign-in emails use the custom scheme (the mail template is not in this
   repo). Fix: send sign-in links on a verified https App Link / universal link; one-time, short-lived token.
   MASVS-PLATFORM · MASWE-0029 · CWE-939

Hardening: rn-app/src/sec/session.ts:8 refresh token in AsyncStorage (app-private, L2 — § 1, precedent § 42) ·
  rn-app/src/sec/oauth.ts:4 OAuth on a custom scheme with PKCE on (§ 4) · android-native res/xml/network_config.xml:14
  pin set expired 2025-06-30, so pinning is absent (§ 8, L1)
Refuted: android-native/app/src/main/java/com/acme/notes/perf/Leaks.kt:32 (3e: receiver for a protected system broadcast)

Class coverage:
  §1 storage, clipboard, screens   checked (6 hits → 0 findings, 1 Hardening)
  §2 transport and trust           checked (3 hits → 1 finding)
  §3 IPC                           checked (4 hits → 1 finding, 1 Refuted)
  §4 links                         checked (5 hits → 1 finding, 1 Hardening)
  §5 WebView                       checked (2 hits → 0 findings: bundled HTML; onMessage checks nativeEvent.url)
  §6 release config, logs          checked (5 hits → 0 findings: every log line behind __DEV__ or stripped)
  §7 shipped secrets, crypto       checked (3 hits → 0 findings: Firebase config, pk_live_) · 7e blocked (no release artifact; build one or read CI's)
  §8 pinning and resilience        inventory (android-native: expired pin set, RootBeer; other roots: none) · posture L1
```

`[report-with-fix]` — the attacker's path, the effective config and the release reachability are read end to end, and the fix is concrete. `[report-flagged]` — the config or code is unsafe but one link is unproven (a merged manifest not built, a backend template not in the repo, a provisioning profile that may override the file); the row names that link. The class-coverage block lists **all eight** classes on every run: `checked (k hits → m findings)` only when the class's grep ran on a lane that is present, `N/A (<reason>)` when no root carries the surface (no manifest, no WebView dependency), `blocked (<reason>)` when the grep could not run or the effective config could not be read. A source-manifest run says so in the header. No mobile root at all prints one line — `no mobile tree: every class N/A` — and nothing else.

## False positives / gotchas

The calibration is [`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 42–48; a match makes a row Hardening and the precedent's flip evidence makes it a candidate again.
- **Secure stores and private storage** (§ 42): Keychain, expo-secure-store, react-native-keychain, flutter_secure_storage and Keystore-keyed MMKV are the fix, not a hit. App-private plaintext is L2 — Hardening on L1. EncryptedSharedPreferences is a deprecation advisory. `AsyncStorage.setItem('theme', …)` never prints; `tokenExpiryShown` prints and is refuted on read.
- **Pinning** (§ 43): no pinning on L1 is not a finding; an expired pin set is reported as absent, never as present.
- **Not secrets** (§ 44): Firebase `AIza…` keys and config files, `pk_live_`, a Supabase anon key behind RLS, a Sentry DSN, OAuth client ids. The flip: a Gemini Developer API key is critical, and anything client code reads is public whatever its EAS visibility.
- **Transport exemptions** (§ 45): `NSAllowsArbitraryLoads` beside `…InWebContent` / `…ForMedia` / `NSAllowsLocalNetworking` is inert and the perl block does not print it; cleartext to `10.0.2.2` / `localhost` and user CAs inside `<debug-overrides>` are dropped before printing. A `src/debug/AndroidManifest.xml` cleartext flag is excluded by `X`.
- **IPC exemptions** (§ 46): the launcher activity, a `signature` permission, a mutable `PendingIntent` on an explicit Intent for RemoteInput or bubbles; StrandHogg at `minSdk` ≥ 30. A runtime receiver that listens only to protected system broadcasts needs no exported flag (3e prints it; the read refutes it).
- **Capture guards** (§ 47): `FLAG_SECURE` / `.privacySensitive` only on PIN, card, seed-phrase and OTP screens. Grep 1e names the screens it matched; a root with a guard anywhere is not printed — read whether the guard covers the printed screen.
- **The RN `debug.keystore`** (§ 48) with password `android` is fine unless the release `signingConfig` uses it; grep 6b drops the `android` password literal.
- **Debug-only entitlements and configs**: `get-task-allow` or `aps-environment = development` in a file only the Debug configuration names (the reading of § 29); a staging trust-all class only a staging flavor compiles — read the source sets.
- **Marketing schemes** (§ 27): a custom scheme that opens a public screen carries no credential; grep 4b keys on auth, reset and payment words, so `acmedriver://promo/spring` never prints.
- **A static WebView** (`source={{ html }}`, bundled HTML, no `onMessage` / `injectedJavaScript`) is not printed even with `originWhitelist={['*']}`.
- **Expo CNG** (§ 3): a finding in a generated `android/` / `ios/` is re-located to `app.json` / `app.config.*` or a config plugin; the pre-flight prints a `cng` line per absent folder. **CI-failing lints** (§ 4) are not re-reported.
- **Measured** (2026-10-05, ripgrep 15.2.0, bash 3.2 and zsh, stdin piped or not — identical sets): on a seeded six-root mobile sample (bare RN, two Expo apps, Flutter, Kotlin, Swift) the script prints 85 lines; every seeded defect surfaces, and no decoy prints except lines handed to the read step by design — a PKCE-protected OAuth redirect, the scheme and link-entry inventory (4a, 4c), a system-broadcast receiver (3e), a WebView whose `onMessage` only logs, and the two Firebase config files labelled with § 44. Run from the repo root it prints the single N/A line (its root computation excludes `fixtures/`); run inside `benchmarks/fixtures/mobile-defects/` it prints 46 lines covering that fixture's 12 security seeds.

## When to run

- On any diff touching `AndroidManifest.xml`, a `network_security_config`, `Info.plist`, an `.entitlements` file, `app.json` / `app.config.*`, a Gradle build type or signing config, a storage, HTTP-client, WebView, deep-link or crypto call in mobile code, an `EXPO_PUBLIC_*` / `--dart-define` / `.env` value — the `/security-audit` dispatch signal.
- On every whole-repo `/security-audit` with an `android/`, `ios/`, React Native, Expo or Flutter tree, reporting all eight classes; a repo with none prints the N/A line.
- Before a store release: §§ 6–7 against the release artifact itself (M7, M8), not only the source.

## Halt conditions

- Do not report a critical or high row as confirmed until a verifier other than the run that found it has passed it through [`finding-verification.md`](../../../../snippets/finding-verification.md). Under `/security-audit` that happens in Phase 4; run standalone, it applies the protocol as a separate second pass, tags those rows `self-verified`, and lists the rest as Unconfirmed, Hardening or Refuted — never drops them.
- Halt on any finding without the config or code `<file:line>` + the attacker capability + the concrete fix + a severity on the MASWE profile (§ Severity).
- Do not report a grep hit as a finding. Every row is a hit **plus** a read of the enclosing component or config **plus** the attacker's path to it in a release build.
- Do not rate an § 3 / § 4 / § 6 row `[report-with-fix]` from a source manifest alone; it stays `[report-flagged]` until the merged manifest or the artifact confirms it.
- Do not report an L2 or resilience class above Hardening on a project that declares no L2 posture.
- Proofs run only on the owner's own debug or test build, on an emulator or a test device the owner controls, with seeded canary values ([`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile). No booted device → the D-tier proof is `SKIPPED` and the row stays Unconfirmed with the settling command, never confirmed.
- Do not print "no mobile issues" without the class-coverage block; a class whose grep did not run is `blocked`, never `checked`.
- Do not re-derive a class another skill owns (§ Boundaries) — cross-link it.

## Related

- `/security-audit` — dispatches here on any `android/`, `ios/`, React Native, Expo or Flutter tree and on every whole-repo run, and folds this skill's class-coverage block into its class ledger; `/security-fix` closes the findings.
- `@auth-reviewer` — token lifecycle and session design; this skill reports where the token is stored and transported on the device.
- `@data-privacy-reviewer` — PII flow to analytics and third parties; device logs and backups are § 6 here.
- `secret-scan-method` — committed keystores, credentials across history, rotation. `xss-scan` § 12a — `EXPO_PUBLIC_*` in a web build. `injection-scan`, `data-exposure-scan` — the server-side counterparts (§ Boundaries).
- Mobile pack, when installed: `mobile-lifecycle-scan` § 5 (link payload robustness), `offline-sync-scan` § 5 (identity bleed, Keychain wipe), `store-compliance-scan` (ATS justification, privacy manifest, target API), `native-bridge-audit` § 7 (routes WebView bridges here), `app-size-scan` (shrinking). Without it, the robustness and wipe rows are reported here as § Boundaries says.
- [`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 42–48 (calibration), § 50–59 (version gates); [`exploit-payloads.md`](../../../../snippets/exploit-payloads.md) § Mobile M1–M8 (confirmation steps on the owner's own build).

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.