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 +3 -0
- appsleuth/__main__.py +4 -0
- appsleuth/cli.py +426 -0
- appsleuth/dataaudit.py +395 -0
- appsleuth/devdata.py +345 -0
- appsleuth/diff.py +356 -0
- appsleuth/engine.py +3362 -0
- appsleuth/info.py +232 -0
- appsleuth/triage.py +203 -0
- appsleuth-1.0.1.dist-info/METADATA +64 -0
- appsleuth-1.0.1.dist-info/RECORD +15 -0
- appsleuth-1.0.1.dist-info/WHEEL +5 -0
- appsleuth-1.0.1.dist-info/entry_points.txt +2 -0
- appsleuth-1.0.1.dist-info/licenses/LICENSE +21 -0
- appsleuth-1.0.1.dist-info/top_level.txt +1 -0
appsleuth/__init__.py
ADDED
appsleuth/__main__.py
ADDED
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)
|