appsleuth 1.0.1__py3-none-any.whl

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.
appsleuth/__init__.py ADDED
@@ -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"
appsleuth/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
appsleuth/cli.py ADDED
@@ -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)