appsleuth 1.0.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rudranetra India Private Limited (https://rudranetra.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: appsleuth
3
+ Version: 1.0.1
4
+ Summary: Static security scanner, info, diff and app-data auditor for iOS (IPA) and Android (APK/APKS/XAPK/AAB) apps
5
+ License: MIT
6
+ Project-URL: Homepage, https://rudranetra.com/tools
7
+ Project-URL: Source, https://github.com/RansomWhisker/AppSleuth
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # AppSleuth
14
+
15
+ > A tool by [**rudranetra.com**](https://rudranetra.com) · more at [rudranetra.com/tools](https://rudranetra.com/tools)
16
+
17
+ One tool for mobile app security triage. Works on **IPA**, **APK**, **APKS/XAPK** and **AAB**. Python standard library only.
18
+
19
+ | Command | What it does |
20
+ |---|---|
21
+ | `appsleuth scan app.ipa` | Static security scan (secrets, ATS / network config, exported components, weak crypto, hardening, hybrid web layer) |
22
+ | `appsleuth info app.apk` | One-screen summary: identity, signing, permissions, exported components, libraries, posture flags |
23
+ | `appsleuth diff old new` | Compare two builds: files by category, posture, findings, and a list of **regressions** |
24
+ | `appsleuth data` | Pull an app's data container from a jailbroken iPhone (SSH) or an Android device (adb) and audit it, or audit a directory you already pulled |
25
+
26
+ `appsleuth <file>` is shorthand for `scan`.
27
+
28
+ ## Install
29
+
30
+ pipx install git+https://github.com/RansomWhisker/AppSleuth # recommended
31
+ pip install . # or, from a clone: gives you the `appsleuth` command
32
+ python3 -m appsleuth … # or run in place
33
+
34
+ ## Examples
35
+
36
+ appsleuth scan app.ipa --fail-on HIGH
37
+ appsleuth info RSWealth.apks --full
38
+ appsleuth diff v1.apk v2.apk --fail-on-regression
39
+ appsleuth data --ios --host 9 -b com.example.app
40
+ appsleuth data --android -p com.example.app --method auto
41
+ appsleuth data --audit-dir ./pulled --platform android
42
+
43
+ Reports (`report.json/md/html`) are written to `-o DIR`.
44
+
45
+ ## Keeping false positives low
46
+
47
+ * **At detection**: placeholder and sample secrets dropped; public client identifiers (Google API keys, OAuth client IDs, Razorpay key IDs) reported as INFO; JWTs must decode; PEM needs a real body; whole-word crypto matching; digests, UUIDs and identifiers filtered from the entropy scan; documentation/schema URLs ignored; launcher activities not "exported"; Swift binaries not flagged for stack canaries.
48
+ * **Corroboration** (`triage`): cleartext URLs are demoted when ATS or the Android target SDK already blocks cleartext; config-split APKs don't repeat base-manifest findings.
49
+ * **Yours**: `--ignore-file` (default `.appsleuth-ignore`) with `title:`, `location:`, `evidence:`, `fingerprint:`, `severity:` rules and ` -- reason`. `--min-severity`, `--min-confidence`.
50
+ * Nothing is silently lost: `report.json` has a `triage` section listing everything demoted, dropped, ignored or hidden. `--no-triage` shows raw output.
51
+
52
+ Findings are leads for manual review, not confirmed vulnerabilities; each carries a `confidence`.
53
+
54
+ ## `appsleuth data` safety
55
+
56
+ Values are **redacted by default** (`--show-values` shows a short preview, never the full secret). Pulled archives are extracted defensively (regular files only, no links, no traversal, size/count caps); SQLite is opened read-only with an authorizer; XML with a DOCTYPE is refused; host/user/package inputs are validated and shell-quoted. Only use it on devices and apps you are authorised to test.
57
+
58
+ ## Tests
59
+
60
+ python3 -m unittest discover -s tests -t .
61
+
62
+ ## Licence
63
+
64
+ [MIT](LICENSE) © Rudranetra India Private Limited — [rudranetra.com](https://rudranetra.com)
@@ -0,0 +1,52 @@
1
+ # AppSleuth
2
+
3
+ > A tool by [**rudranetra.com**](https://rudranetra.com) · more at [rudranetra.com/tools](https://rudranetra.com/tools)
4
+
5
+ One tool for mobile app security triage. Works on **IPA**, **APK**, **APKS/XAPK** and **AAB**. Python standard library only.
6
+
7
+ | Command | What it does |
8
+ |---|---|
9
+ | `appsleuth scan app.ipa` | Static security scan (secrets, ATS / network config, exported components, weak crypto, hardening, hybrid web layer) |
10
+ | `appsleuth info app.apk` | One-screen summary: identity, signing, permissions, exported components, libraries, posture flags |
11
+ | `appsleuth diff old new` | Compare two builds: files by category, posture, findings, and a list of **regressions** |
12
+ | `appsleuth data` | Pull an app's data container from a jailbroken iPhone (SSH) or an Android device (adb) and audit it, or audit a directory you already pulled |
13
+
14
+ `appsleuth <file>` is shorthand for `scan`.
15
+
16
+ ## Install
17
+
18
+ pipx install git+https://github.com/RansomWhisker/AppSleuth # recommended
19
+ pip install . # or, from a clone: gives you the `appsleuth` command
20
+ python3 -m appsleuth … # or run in place
21
+
22
+ ## Examples
23
+
24
+ appsleuth scan app.ipa --fail-on HIGH
25
+ appsleuth info RSWealth.apks --full
26
+ appsleuth diff v1.apk v2.apk --fail-on-regression
27
+ appsleuth data --ios --host 9 -b com.example.app
28
+ appsleuth data --android -p com.example.app --method auto
29
+ appsleuth data --audit-dir ./pulled --platform android
30
+
31
+ Reports (`report.json/md/html`) are written to `-o DIR`.
32
+
33
+ ## Keeping false positives low
34
+
35
+ * **At detection**: placeholder and sample secrets dropped; public client identifiers (Google API keys, OAuth client IDs, Razorpay key IDs) reported as INFO; JWTs must decode; PEM needs a real body; whole-word crypto matching; digests, UUIDs and identifiers filtered from the entropy scan; documentation/schema URLs ignored; launcher activities not "exported"; Swift binaries not flagged for stack canaries.
36
+ * **Corroboration** (`triage`): cleartext URLs are demoted when ATS or the Android target SDK already blocks cleartext; config-split APKs don't repeat base-manifest findings.
37
+ * **Yours**: `--ignore-file` (default `.appsleuth-ignore`) with `title:`, `location:`, `evidence:`, `fingerprint:`, `severity:` rules and ` -- reason`. `--min-severity`, `--min-confidence`.
38
+ * Nothing is silently lost: `report.json` has a `triage` section listing everything demoted, dropped, ignored or hidden. `--no-triage` shows raw output.
39
+
40
+ Findings are leads for manual review, not confirmed vulnerabilities; each carries a `confidence`.
41
+
42
+ ## `appsleuth data` safety
43
+
44
+ Values are **redacted by default** (`--show-values` shows a short preview, never the full secret). Pulled archives are extracted defensively (regular files only, no links, no traversal, size/count caps); SQLite is opened read-only with an authorizer; XML with a DOCTYPE is refused; host/user/package inputs are validated and shell-quoted. Only use it on devices and apps you are authorised to test.
45
+
46
+ ## Tests
47
+
48
+ python3 -m unittest discover -s tests -t .
49
+
50
+ ## Licence
51
+
52
+ [MIT](LICENSE) © Rudranetra India Private Limited — [rudranetra.com](https://rudranetra.com)
@@ -0,0 +1,3 @@
1
+ """appsleuth - static security analysis, inspection and comparison of mobile apps
2
+ (APK / APKS / XAPK / AAB / IPA), plus on-device data auditing."""
3
+ __version__ = "1.0.1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,426 @@
1
+ """appsleuth command line.
2
+
3
+ appsleuth scan <app|dir> static security scan (APK / APKS / XAPK / AAB / IPA)
4
+ appsleuth info <app> inspect an app: identity, signing, permissions, components...
5
+ appsleuth diff <old> <new> compare two builds and list regressions
6
+ appsleuth data ... pull + audit an app's data container from a device
7
+ appsleuth <app> same as `scan` (backward compatible)
8
+ """
9
+ import argparse
10
+ import getpass
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from . import __version__, diff as diff_mod, engine, info as info_mod, triage
20
+
21
+ COMMANDS = ("scan", "info", "diff", "data", "version")
22
+ _SEV_RANK = {"INFO": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3}
23
+
24
+
25
+ # ------------------------------------------------------------------ terminal
26
+
27
+ class Term:
28
+ on = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
29
+ codes = {"HIGH": "1;31", "MEDIUM": "33", "LOW": "34", "INFO": "90", "bold": "1", "dim": "2", "ok": "32"}
30
+
31
+ @classmethod
32
+ def c(cls, text: str, kind: str) -> str:
33
+ return f"\033[{cls.codes[kind]}m{text}\033[0m" if cls.on else text
34
+
35
+
36
+ def die(msg: str, code: int = 1):
37
+ print(f"error: {msg}", file=sys.stderr)
38
+ sys.exit(code)
39
+
40
+
41
+ def _counts(s: Dict[str, int]) -> str:
42
+ return " ".join(Term.c(f"{s.get(k, 0)} {k.title()}", k) for k in ("HIGH", "MEDIUM", "LOW", "INFO"))
43
+
44
+
45
+ def print_findings(result: Dict[str, Any], *, verbose: bool = False, show: str = "LOW"):
46
+ shown = [f for f in result["findings"] if _SEV_RANK[f["severity"]] >= _SEV_RANK[show]]
47
+ hidden = len(result["findings"]) - len(shown)
48
+ for f in shown:
49
+ tag = Term.c(f"[{f['severity']}]", f["severity"])
50
+ conf = "" if f.get("confidence") == "confirmed" else Term.c(" (heuristic)", "dim")
51
+ print(f" {tag} {f['title']}{conf}")
52
+ if f.get("location"):
53
+ print(Term.c(f" {f['location'][:110]}", "dim"))
54
+ if verbose:
55
+ print(f" {f['detail']}")
56
+ if f.get("evidence"):
57
+ print(f" evidence: {f['evidence'][:300]}")
58
+ if hidden:
59
+ print(Term.c(f" … {hidden} lower-severity finding(s) in the report (use --show INFO to list)", "dim"))
60
+
61
+
62
+ def _append_triage_md(path: str, result: Dict[str, Any]):
63
+ t = result.get("triage")
64
+ if not t:
65
+ return
66
+ lines = ["", "## Triage (what was filtered, and why)", ""]
67
+ for d in t["demoted"]:
68
+ lines.append(f"- demoted **{d['title']}** {d['from']} → {d['to']}: {d['why']}")
69
+ for i in t["ignored_by_rule"]:
70
+ lines.append(f"- ignored **{i['title']}** by rule `{i['rule']}`" + (f" ({i['reason']})" if i["reason"] else ""))
71
+ if t["hidden_by_threshold"]:
72
+ lines.append(f"- {len(t['hidden_by_threshold'])} finding(s) below the requested threshold are in report.json")
73
+ if t["dropped"]:
74
+ lines.append(f"- {len(t['dropped'])} duplicate finding(s) collapsed")
75
+ if len(lines) == 3:
76
+ lines.append("Nothing was demoted, ignored or hidden.")
77
+ with open(path, "a") as fh:
78
+ fh.write("\n".join(lines) + "\n")
79
+
80
+
81
+ def write_reports(result: Dict[str, Any], outdir: str, title: Optional[str] = None):
82
+ os.makedirs(outdir, exist_ok=True)
83
+ with open(os.path.join(outdir, "report.json"), "w") as fh:
84
+ json.dump(result, fh, indent=2, default=str)
85
+ md = os.path.join(outdir, "report.md")
86
+ engine.write_markdown_report(result, md)
87
+ if title:
88
+ text = open(md).read().replace("# Mobile app static security scan:", f"# {title}:", 1)
89
+ open(md, "w").write(text)
90
+ _append_triage_md(md, result)
91
+ engine.write_html_report(result, os.path.join(outdir, "report.html"))
92
+
93
+
94
+ def _rules(args, target: str) -> List[triage.IgnoreRule]:
95
+ path = getattr(args, "ignore_file", None) or triage.find_default_ignore_file(target)
96
+ if not path:
97
+ return []
98
+ try:
99
+ rules = triage.load_ignore_file(path)
100
+ except (OSError, ValueError) as e:
101
+ die(f"cannot read ignore file: {e}", 2)
102
+ print(Term.c(f"[*] Using ignore rules from {path} ({len(rules)})", "dim"))
103
+ return rules
104
+
105
+
106
+ def _scan_triaged(path: str, args, rules, tmp: str, keep: bool = False) -> Dict[str, Any]:
107
+ try:
108
+ res = engine.scan(path, tmp, save_extracted=keep)
109
+ except ValueError as e:
110
+ die(str(e), 1)
111
+ except Exception as e:
112
+ die(f"unexpected failure while scanning '{path}': {type(e).__name__}: {e}", 1)
113
+ if getattr(args, "no_triage", False):
114
+ res["findings"] = [dict(f, fingerprint=triage._fp(f)) for f in res["findings"]]
115
+ return res
116
+ return triage.apply(res, rules=rules, min_severity=getattr(args, "min_severity", None),
117
+ min_confidence=getattr(args, "min_confidence", None))
118
+
119
+
120
+ # ------------------------------------------------------------------------ scan
121
+
122
+ def cmd_scan(a):
123
+ if not os.path.exists(a.target):
124
+ die(f"file not found: {a.target}", 2)
125
+ outdir = a.outdir or f"scan_{os.path.splitext(os.path.basename(a.target.rstrip('/')))[0]}"
126
+ if os.path.isdir(a.target):
127
+ b = engine.batch_scan(a.target, outdir, recursive=a.recursive, save_extracted=a.keep_extracted)
128
+ print(f"Scanned {b['count']} file(s) under {a.target}; summary in {outdir}/batch_summary.md")
129
+ for r in b["results"]:
130
+ if r["error"]:
131
+ print(f" ! {r['file']}: {r['error']}")
132
+ return
133
+
134
+ rules = _rules(a, a.target)
135
+ tmp = tempfile.mkdtemp(prefix="appsleuth_")
136
+ try:
137
+ result = _scan_triaged(a.target, a, rules, outdir if a.keep_extracted else tmp, keep=a.keep_extracted)
138
+ finally:
139
+ shutil.rmtree(tmp, ignore_errors=True)
140
+
141
+ if a.baseline:
142
+ try:
143
+ result = engine.apply_baseline(result, engine.load_baseline(a.baseline), a.baseline)
144
+ except Exception as e:
145
+ die(f"could not read baseline '{a.baseline}': {e}", 2)
146
+ if a.write_baseline:
147
+ engine.write_baseline(result, a.write_baseline)
148
+ print(f"Baseline written to {a.write_baseline}")
149
+
150
+ write_reports(result, outdir)
151
+ if a.format == "json":
152
+ print(json.dumps(result, indent=2, default=str))
153
+ elif not a.quiet:
154
+ m = result["meta"]
155
+ ident = m.get("package") or m.get("bundle_id") or os.path.basename(a.target)
156
+ ver = m.get("versionName") or m.get("version") or ""
157
+ print(Term.c(f"{ident} {ver}", "bold") + f" [{result['detected_type'].upper()}]")
158
+ print_findings(result, verbose=a.verbose, show=a.show)
159
+ print()
160
+ print(_counts(result["summary"]) + (f" ({result['suppressed_count']} hidden)" if result.get("suppressed_count") else ""))
161
+ print(f"Reports: {outdir}/report.html report.md report.json")
162
+
163
+ if a.fail_on:
164
+ worst = max((_SEV_RANK[f["severity"]] for f in result["findings"]), default=-1)
165
+ if worst >= _SEV_RANK[a.fail_on]:
166
+ print(f"--fail-on {a.fail_on}: finding at or above this severity.", file=sys.stderr)
167
+ sys.exit(3)
168
+
169
+
170
+ # ------------------------------------------------------------------------ info
171
+
172
+ def cmd_info(a):
173
+ if not os.path.isfile(a.target):
174
+ die(f"file not found: {a.target}", 2)
175
+ tmp = tempfile.mkdtemp(prefix="appsleuth_")
176
+ try:
177
+ res = _scan_triaged(a.target, a, _rules(a, a.target), tmp)
178
+ finally:
179
+ shutil.rmtree(tmp, ignore_errors=True)
180
+ s = info_mod.summarize(res, a.target)
181
+ if a.json:
182
+ print(json.dumps(s, indent=2, default=str))
183
+ else:
184
+ print(info_mod.render_text(s, full=a.full))
185
+
186
+
187
+ # ------------------------------------------------------------------------ diff
188
+
189
+ def cmd_diff(a):
190
+ for p in (a.old, a.new):
191
+ if not os.path.isfile(p):
192
+ die(f"file not found: {p}", 2)
193
+ rules = _rules(a, a.new)
194
+ tmp = tempfile.mkdtemp(prefix="appsleuth_")
195
+ try:
196
+ d = diff_mod.diff_builds(a.old, a.new, lambda p: _scan_triaged(p, a, rules, tmp))
197
+ finally:
198
+ shutil.rmtree(tmp, ignore_errors=True)
199
+ outdir = a.outdir or f"diff_{os.path.splitext(os.path.basename(a.old))[0]}_vs_{os.path.splitext(os.path.basename(a.new))[0]}"
200
+ os.makedirs(outdir, exist_ok=True)
201
+ with open(os.path.join(outdir, "diff.json"), "w") as fh:
202
+ json.dump(d, fh, indent=2, default=str)
203
+ with open(os.path.join(outdir, "diff.md"), "w") as fh:
204
+ fh.write(diff_mod.render_markdown(d))
205
+
206
+ if a.format == "json":
207
+ print(json.dumps(d, indent=2, default=str))
208
+ else:
209
+ o, n = d["old"], d["new"]
210
+ print(Term.c(f"{n['id']}", "bold") + f" {o['version']} → {n['version']}")
211
+ for w in d["warnings"]:
212
+ print(Term.c(f" ! {w}", "MEDIUM"))
213
+ arts = d["artifact"]
214
+ print(f" package: {len(arts['added'])} added, {len(arts['removed'])} removed, {len(arts['modified'])} modified files")
215
+ regs = [r for r in d["regressions"] if r["severity"] != "INFO" or a.verbose]
216
+ print(f"\nRegressions: {len([r for r in d['regressions'] if r['severity'] != 'INFO'])}")
217
+ for r in regs:
218
+ print(f" {Term.c('[' + r['severity'] + ']', r['severity'])} {r['text']}")
219
+ f = d["findings"]
220
+ print(f"\nFindings: {len(f['new'])} new, {len(f['resolved'])} resolved")
221
+ print(f"Report: {outdir}/diff.md diff.json")
222
+ if a.fail_on_regression:
223
+ if any(_SEV_RANK[r["severity"]] >= _SEV_RANK[a.fail_on_regression] for r in d["regressions"]):
224
+ print(f"--fail-on-regression {a.fail_on_regression}: regression found.", file=sys.stderr)
225
+ sys.exit(4)
226
+
227
+
228
+ # ------------------------------------------------------------------------ data
229
+
230
+ def cmd_data(a):
231
+ from . import dataaudit, devdata
232
+
233
+ def audit_and_report(root: str, platform: str, out: str, manifest=None):
234
+ res = dataaudit.audit_dir(root, platform, reveal=a.show_values, manifest=manifest)
235
+ res = triage.apply(res, rules=_rules(a, root), min_severity=a.min_severity, corroborate=False)
236
+ write_reports(res, out, "App data audit")
237
+ print(f"\n{_counts(res['summary'])}")
238
+ print_findings(res, verbose=a.verbose, show=a.show)
239
+ print(f"Reports: {out}/report.html report.md report.json")
240
+ return res
241
+
242
+ if a.audit_dir:
243
+ if not os.path.isdir(a.audit_dir):
244
+ die(f"not a directory: {a.audit_dir}", 2)
245
+ audit_and_report(a.audit_dir, a.platform or "ios", a.outdir or "data_audit")
246
+ return
247
+
248
+ plat = "android" if a.android else "ios" if a.ios else a.platform
249
+ if not plat:
250
+ die("choose a source: --ios, --android, or --audit-dir DIR", 2)
251
+ cap = a.max_mb << 20
252
+ try:
253
+ if plat == "ios":
254
+ host, user, pw = a.host or os.environ.get("APPSLEUTH_HOST"), a.user, os.environ.get("SSH_PASSWORD")
255
+ if not host:
256
+ if not sys.stdin.isatty():
257
+ die("--host is required", 2)
258
+ user = input(f"Username [{user}]: ").strip() or user
259
+ host = input("Device IP (just the last number works, e.g. 9 = 192.168.1.9): ").strip()
260
+ pw = getpass.getpass("Password (blank = use SSH key/prompt): ") or None
261
+ host = devdata.expand_host(host or "")
262
+ if not host:
263
+ die("An IP address is required.", 2)
264
+ ssh = devdata.SSH(host, a.port, user, a.key, pw)
265
+ ssh.open()
266
+ apps = devdata.ios_list_containers(ssh, a.include_system)
267
+ app = _pick(apps, a.bundle_id, lambda x: x["bundle_id"],
268
+ lambda i, x: f"{i:3}. {x['bundle_id']}", "bundle id")
269
+ if app is None:
270
+ return
271
+ out = os.path.join(a.outdir or "appdata", devdata.clean(app["bundle_id"]))
272
+ print(f"[*] Pulling data container for {app['bundle_id']} ...")
273
+ manifest = devdata.ios_pull(ssh, app, os.path.join(out, "data"), cap)
274
+ else:
275
+ serials = devdata.android_devices()
276
+ if not serials:
277
+ die("no Android device found (check `adb devices`)", 1)
278
+ serial = a.serial or (serials[0] if len(serials) == 1 else None)
279
+ if not serial:
280
+ die("several devices connected; pick one with --serial: " + ", ".join(serials), 2)
281
+ pkgs = devdata.android_list_packages(serial)
282
+ pkg = _pick(pkgs, a.package, lambda x: x, lambda i, x: f"{i:3}. {x}", "package")
283
+ if pkg is None:
284
+ return
285
+ out = os.path.join(a.outdir or "appdata", pkg)
286
+ print(f"[*] Pulling data for {pkg} (method: {a.method}) ...")
287
+ manifest = devdata.android_pull(serial, pkg, os.path.join(out, "data"), a.method, cap)
288
+ except devdata.PullError as e:
289
+ die(str(e), 1)
290
+
291
+ os.chmod(out, 0o700)
292
+ mf = {k: v for k, v in manifest.items() if k != "modes"}
293
+ with open(os.path.join(out, "pull_manifest.json"), "w") as fh:
294
+ json.dump(manifest, fh, indent=1)
295
+ print(f"[+] {mf['files']} files, {mf['bytes'] / 1e6:.1f} MB via {mf['method']} -> {out}/data")
296
+ if mf.get("skipped_links_or_special"):
297
+ print(f" ({mf['skipped_links_or_special']} symlinks/special files skipped)")
298
+ if not a.no_audit:
299
+ audit_and_report(os.path.join(out, "data"), plat, out, manifest)
300
+ print(Term.c("Note: the pulled data contains real user data and tokens. Treat it as sensitive; "
301
+ "delete it when done (or re-run with --no-keep-data).", "dim"))
302
+ if a.no_keep_data:
303
+ shutil.rmtree(os.path.join(out, "data"), ignore_errors=True)
304
+ print("[*] Pulled data deleted; reports kept.")
305
+
306
+
307
+ def _pick(items, wanted: Optional[str], key, fmt, noun: str):
308
+ if wanted:
309
+ exact = [x for x in items if key(x) == wanted] or [x for x in items if wanted.lower() in key(x).lower()]
310
+ if not exact:
311
+ die(f"no {noun} matching '{wanted}'", 1)
312
+ if len(exact) > 1:
313
+ print(f"Several {noun}s match:")
314
+ for i, x in enumerate(exact, 1):
315
+ print(fmt(i, x))
316
+ sys.exit(1)
317
+ return exact[0]
318
+ if not items:
319
+ die(f"no {noun}s found", 1)
320
+ if not sys.stdin.isatty():
321
+ for i, x in enumerate(items, 1):
322
+ print(fmt(i, x))
323
+ return None
324
+ print(f"Installed apps (most recently used first):")
325
+ for i, x in enumerate(items[:25], 1):
326
+ print(fmt(i, x))
327
+ choice = input("Pick a number (or Enter to quit): ").strip()
328
+ if not choice.isdigit() or not 1 <= int(choice) <= min(25, len(items)):
329
+ return None
330
+ return items[int(choice) - 1]
331
+
332
+
333
+ # ------------------------------------------------------------------------ parser
334
+
335
+ def build_parser() -> argparse.ArgumentParser:
336
+ ap = argparse.ArgumentParser(prog="appsleuth", description=__doc__,
337
+ formatter_class=argparse.RawDescriptionHelpFormatter)
338
+ ap.add_argument("--version", action="version", version=f"appsleuth {__version__}")
339
+ sub = ap.add_subparsers(dest="cmd", metavar="{scan,info,diff,data}")
340
+
341
+ def triage_opts(p):
342
+ p.add_argument("--ignore-file", help="ignore rules (default: ./.appsleuth-ignore if present)")
343
+ p.add_argument("--min-severity", choices=["INFO", "LOW", "MEDIUM", "HIGH"], help="hide findings below this")
344
+ p.add_argument("--min-confidence", choices=["heuristic", "confirmed"],
345
+ help="'confirmed' hides pattern-based (heuristic) findings entirely")
346
+ p.add_argument("--no-triage", action="store_true", help="raw engine output, no false-positive layer")
347
+
348
+ s = sub.add_parser("scan", help="static security scan of an app (or a directory of apps)")
349
+ s.add_argument("target", help=".apk .apks .xapk .aab .ipa, or a directory (batch)")
350
+ s.add_argument("-o", "--outdir")
351
+ s.add_argument("--recursive", action="store_true", help="batch: recurse into subdirectories")
352
+ s.add_argument("--keep-extracted", action="store_true", help="keep the unpacked app in <outdir>/extracted")
353
+ s.add_argument("--fail-on", choices=["HIGH", "MEDIUM", "LOW", "INFO"], help="exit 3 if a finding >= this remains")
354
+ s.add_argument("--baseline", help="suppress findings listed in this baseline file")
355
+ s.add_argument("--write-baseline", help="write the current findings as a baseline file")
356
+ s.add_argument("--show", choices=["HIGH", "MEDIUM", "LOW", "INFO"], default="LOW",
357
+ help="lowest severity to print to the terminal (default LOW)")
358
+ s.add_argument("--format", choices=["text", "json"], default="text")
359
+ s.add_argument("-v", "--verbose", action="store_true", help="print each finding's detail and evidence")
360
+ s.add_argument("-q", "--quiet", action="store_true")
361
+ triage_opts(s)
362
+ s.set_defaults(fn=cmd_scan)
363
+
364
+ i = sub.add_parser("info", help="inspect an app: identity, signing, permissions, components, libraries")
365
+ i.add_argument("target")
366
+ i.add_argument("--json", action="store_true")
367
+ i.add_argument("--full", action="store_true", help="do not truncate long lists")
368
+ triage_opts(i)
369
+ i.set_defaults(fn=cmd_info)
370
+
371
+ d = sub.add_parser("diff", help="compare two builds of an app and list regressions")
372
+ d.add_argument("old")
373
+ d.add_argument("new")
374
+ d.add_argument("-o", "--outdir")
375
+ d.add_argument("--format", choices=["text", "json"], default="text")
376
+ d.add_argument("--fail-on-regression", choices=["HIGH", "MEDIUM", "LOW"], help="exit 4 on a regression >= this")
377
+ d.add_argument("-v", "--verbose", action="store_true", help="also list informational changes")
378
+ triage_opts(d)
379
+ d.set_defaults(fn=cmd_diff)
380
+
381
+ g = sub.add_parser("data", help="pull and audit an app's data container from a device you own")
382
+ src = g.add_mutually_exclusive_group()
383
+ src.add_argument("--ios", action="store_true", help="jailbroken iPhone over SSH")
384
+ src.add_argument("--android", action="store_true", help="Android device/emulator over adb")
385
+ src.add_argument("--audit-dir", metavar="DIR", help="audit an already-pulled data directory (no device)")
386
+ g.add_argument("--platform", choices=["ios", "android"], help="platform of --audit-dir (default ios)")
387
+ g.add_argument("--host", help="iOS: device IP (9 means 192.168.1.9) or set APPSLEUTH_HOST")
388
+ g.add_argument("--port", type=int, default=22)
389
+ g.add_argument("--user", default="root")
390
+ g.add_argument("--key", help="SSH private key")
391
+ g.add_argument("-b", "--bundle-id", help="iOS: bundle id (or substring)")
392
+ g.add_argument("--include-system", action="store_true", help="iOS: include com.apple.* apps")
393
+ g.add_argument("--serial", help="Android: device serial")
394
+ g.add_argument("-p", "--package", help="Android: package name (or substring)")
395
+ g.add_argument("--method", choices=["auto", "root", "run-as", "backup"], default="auto",
396
+ help="Android access method (default: try root, run-as, backup)")
397
+ g.add_argument("--max-mb", type=int, default=2048, help="refuse containers larger than this")
398
+ g.add_argument("-o", "--outdir")
399
+ g.add_argument("--no-audit", action="store_true", help="only pull; skip the storage audit")
400
+ g.add_argument("--no-keep-data", action="store_true", help="delete the pulled data after auditing")
401
+ g.add_argument("--show-values", action="store_true", help="include a short preview of secret values in the report")
402
+ g.add_argument("--show", choices=["HIGH", "MEDIUM", "LOW", "INFO"], default="LOW")
403
+ g.add_argument("-v", "--verbose", action="store_true")
404
+ g.add_argument("--ignore-file")
405
+ g.add_argument("--min-severity", choices=["INFO", "LOW", "MEDIUM", "HIGH"])
406
+ g.set_defaults(fn=cmd_data)
407
+ return ap
408
+
409
+
410
+ def main(argv: Optional[List[str]] = None):
411
+ argv = list(sys.argv[1:] if argv is None else argv)
412
+ if argv and argv[0] == "version":
413
+ print(f"appsleuth {__version__}")
414
+ return
415
+ if argv and argv[0] not in COMMANDS and not argv[0].startswith("-"):
416
+ argv.insert(0, "scan") # `appsleuth app.apk` == `appsleuth scan app.apk`
417
+ ap = build_parser()
418
+ a = ap.parse_args(argv)
419
+ if not getattr(a, "fn", None):
420
+ ap.print_help()
421
+ sys.exit(2)
422
+ try:
423
+ a.fn(a)
424
+ except KeyboardInterrupt:
425
+ print("\ninterrupted", file=sys.stderr)
426
+ sys.exit(130)