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.
- appsleuth-1.0.1/LICENSE +21 -0
- appsleuth-1.0.1/PKG-INFO +64 -0
- appsleuth-1.0.1/README.md +52 -0
- appsleuth-1.0.1/appsleuth/__init__.py +3 -0
- appsleuth-1.0.1/appsleuth/__main__.py +4 -0
- appsleuth-1.0.1/appsleuth/cli.py +426 -0
- appsleuth-1.0.1/appsleuth/dataaudit.py +395 -0
- appsleuth-1.0.1/appsleuth/devdata.py +345 -0
- appsleuth-1.0.1/appsleuth/diff.py +356 -0
- appsleuth-1.0.1/appsleuth/engine.py +3362 -0
- appsleuth-1.0.1/appsleuth/info.py +232 -0
- appsleuth-1.0.1/appsleuth/triage.py +203 -0
- appsleuth-1.0.1/appsleuth.egg-info/PKG-INFO +64 -0
- appsleuth-1.0.1/appsleuth.egg-info/SOURCES.txt +21 -0
- appsleuth-1.0.1/appsleuth.egg-info/dependency_links.txt +1 -0
- appsleuth-1.0.1/appsleuth.egg-info/entry_points.txt +2 -0
- appsleuth-1.0.1/appsleuth.egg-info/top_level.txt +1 -0
- appsleuth-1.0.1/pyproject.toml +22 -0
- appsleuth-1.0.1/setup.cfg +4 -0
- appsleuth-1.0.1/tests/test_data.py +142 -0
- appsleuth-1.0.1/tests/test_diff.py +64 -0
- appsleuth-1.0.1/tests/test_engine_precision.py +94 -0
- appsleuth-1.0.1/tests/test_triage.py +84 -0
appsleuth-1.0.1/LICENSE
ADDED
|
@@ -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.
|
appsleuth-1.0.1/PKG-INFO
ADDED
|
@@ -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,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)
|