ipa-forge 0.1.0__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.
- ipa_forge/__init__.py +1 -0
- ipa_forge/altstore/__init__.py +1 -0
- ipa_forge/altstore/source.py +44 -0
- ipa_forge/analysis/__init__.py +18 -0
- ipa_forge/analysis/classdump.py +103 -0
- ipa_forge/analysis/diff.py +127 -0
- ipa_forge/analysis/security.py +72 -0
- ipa_forge/analysis/strings.py +40 -0
- ipa_forge/analysis/symbols.py +42 -0
- ipa_forge/analysis/type_encoding.py +164 -0
- ipa_forge/bundle/__init__.py +1 -0
- ipa_forge/bundle/inventory.py +96 -0
- ipa_forge/bundle/ipa.py +81 -0
- ipa_forge/bundle/models.py +58 -0
- ipa_forge/bundle/plist.py +18 -0
- ipa_forge/cli/__init__.py +1 -0
- ipa_forge/cli/analysis.py +225 -0
- ipa_forge/cli/common.py +41 -0
- ipa_forge/cli/hooks.py +357 -0
- ipa_forge/cli/main.py +170 -0
- ipa_forge/gui/__init__.py +1 -0
- ipa_forge/gui/analysis.html +229 -0
- ipa_forge/gui/app.py +304 -0
- ipa_forge/gui/index.html +244 -0
- ipa_forge/hooks/__init__.py +0 -0
- ipa_forge/hooks/scan.py +81 -0
- ipa_forge/hooks/verify.py +348 -0
- ipa_forge/machO/__init__.py +1 -0
- ipa_forge/machO/arch.py +94 -0
- ipa_forge/machO/detect.py +50 -0
- ipa_forge/machO/injector.py +99 -0
- ipa_forge/machO/objc.py +534 -0
- ipa_forge/manifest.py +74 -0
- ipa_forge/patch/__init__.py +1 -0
- ipa_forge/patch/base.py +48 -0
- ipa_forge/patch/binary.py +131 -0
- ipa_forge/patch/dylib.py +59 -0
- ipa_forge/patch/engine.py +48 -0
- ipa_forge/patch/loader.py +88 -0
- ipa_forge/patch/paths.py +19 -0
- ipa_forge/patch/plist.py +61 -0
- ipa_forge/patch/resolver.py +37 -0
- ipa_forge/patch/resource.py +135 -0
- ipa_forge/patch/schema.py +118 -0
- ipa_forge/patch/version.py +25 -0
- ipa_forge/patches.py +76 -0
- ipa_forge/pipeline.py +235 -0
- ipa_forge/signing/__init__.py +1 -0
- ipa_forge/signing/backend.py +111 -0
- ipa_forge/signing/pipeline.py +99 -0
- ipa_forge/signing/profile.py +131 -0
- ipa_forge/signing/provider.py +113 -0
- ipa_forge/signing/reconcile.py +27 -0
- ipa_forge/validators/__init__.py +1 -0
- ipa_forge/validators/archive_validator.py +25 -0
- ipa_forge/validators/bundle_validator.py +27 -0
- ipa_forge/validators/ipa_validator.py +39 -0
- ipa_forge-0.1.0.dist-info/METADATA +914 -0
- ipa_forge-0.1.0.dist-info/RECORD +62 -0
- ipa_forge-0.1.0.dist-info/WHEEL +4 -0
- ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
- ipa_forge-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>ipa-forge — Reverse Engineering</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--accent: #0071e3;
|
|
11
|
+
--accent-hover: #0077ed;
|
|
12
|
+
--bg: #f5f5f7;
|
|
13
|
+
--card: #ffffff;
|
|
14
|
+
--text: #1d1d1f;
|
|
15
|
+
--text-secondary: #6e6e73;
|
|
16
|
+
--border: rgba(0, 0, 0, 0.12);
|
|
17
|
+
--shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
|
18
|
+
--danger: #ff3b30;
|
|
19
|
+
--danger-bg: rgba(255, 59, 48, 0.12);
|
|
20
|
+
--mono-bg: #f5f5f7;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root {
|
|
24
|
+
--bg: #000000;
|
|
25
|
+
--card: #1c1c1e;
|
|
26
|
+
--text: #f5f5f7;
|
|
27
|
+
--text-secondary: #98989d;
|
|
28
|
+
--border: rgba(255, 255, 255, 0.16);
|
|
29
|
+
--shadow: 0 8px 30px rgba(0, 0, 0, 0.6);
|
|
30
|
+
--accent: #0a84ff;
|
|
31
|
+
--accent-hover: #3a9bff;
|
|
32
|
+
--mono-bg: #000000;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
* { box-sizing: border-box; }
|
|
36
|
+
body {
|
|
37
|
+
margin: 0;
|
|
38
|
+
font-family: -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif;
|
|
39
|
+
background: var(--bg);
|
|
40
|
+
color: var(--text);
|
|
41
|
+
padding: 24px;
|
|
42
|
+
}
|
|
43
|
+
.card {
|
|
44
|
+
background: var(--card);
|
|
45
|
+
border-radius: 18px;
|
|
46
|
+
box-shadow: var(--shadow);
|
|
47
|
+
padding: 32px;
|
|
48
|
+
max-width: 900px;
|
|
49
|
+
margin: 0 auto;
|
|
50
|
+
}
|
|
51
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
52
|
+
.subtitle { color: var(--text-secondary); font-size: 13px; margin: 0 0 20px; }
|
|
53
|
+
.subtitle a { color: var(--accent); }
|
|
54
|
+
label { display: block; font-size: 13px; font-weight: 600; margin: 14px 0 6px; }
|
|
55
|
+
select, input[type="text"], input[type="number"], input[type="file"] {
|
|
56
|
+
width: 100%;
|
|
57
|
+
padding: 10px 12px;
|
|
58
|
+
border-radius: 10px;
|
|
59
|
+
border: 1px solid var(--border);
|
|
60
|
+
background: var(--card);
|
|
61
|
+
color: var(--text);
|
|
62
|
+
font-size: 14px;
|
|
63
|
+
}
|
|
64
|
+
.row { display: flex; gap: 12px; }
|
|
65
|
+
.row > * { flex: 1; }
|
|
66
|
+
.hidden { display: none; }
|
|
67
|
+
button {
|
|
68
|
+
margin-top: 18px;
|
|
69
|
+
padding: 12px 20px;
|
|
70
|
+
border: none;
|
|
71
|
+
border-radius: 12px;
|
|
72
|
+
background: var(--accent);
|
|
73
|
+
color: #fff;
|
|
74
|
+
font-size: 14px;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
button:hover { background: var(--accent-hover); }
|
|
79
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
80
|
+
.status {
|
|
81
|
+
margin-top: 14px;
|
|
82
|
+
border-radius: 10px;
|
|
83
|
+
padding: 10px 14px;
|
|
84
|
+
font-size: 13px;
|
|
85
|
+
display: none;
|
|
86
|
+
}
|
|
87
|
+
.status.error { display: block; background: var(--danger-bg); color: var(--danger); }
|
|
88
|
+
pre {
|
|
89
|
+
margin-top: 16px;
|
|
90
|
+
background: var(--mono-bg);
|
|
91
|
+
border: 1px solid var(--border);
|
|
92
|
+
border-radius: 10px;
|
|
93
|
+
padding: 16px;
|
|
94
|
+
max-height: 60vh;
|
|
95
|
+
overflow: auto;
|
|
96
|
+
font-size: 12px;
|
|
97
|
+
line-height: 1.5;
|
|
98
|
+
white-space: pre-wrap;
|
|
99
|
+
word-break: break-word;
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<div class="card">
|
|
105
|
+
<h1>ipa-forge — Reverse Engineering</h1>
|
|
106
|
+
<p class="subtitle">Read-only static analysis of an IPA. Back to <a href="/">the patcher</a>.</p>
|
|
107
|
+
|
|
108
|
+
<label for="action">Action</label>
|
|
109
|
+
<select id="action">
|
|
110
|
+
<option value="classdump">Class-dump (Objective-C runtime metadata)</option>
|
|
111
|
+
<option value="strings">Strings</option>
|
|
112
|
+
<option value="security">Security posture</option>
|
|
113
|
+
<option value="diff">Diff two versions</option>
|
|
114
|
+
</select>
|
|
115
|
+
|
|
116
|
+
<div id="fieldsSingle">
|
|
117
|
+
<label for="fileSingle">IPA</label>
|
|
118
|
+
<input type="file" id="fileSingle" accept=".ipa" />
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div id="fieldsDiff" class="hidden">
|
|
122
|
+
<label for="fileOld">Old-version IPA</label>
|
|
123
|
+
<input type="file" id="fileOld" accept=".ipa" />
|
|
124
|
+
<label for="fileNew">New-version IPA</label>
|
|
125
|
+
<input type="file" id="fileNew" accept=".ipa" />
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div id="fieldsClassdump" class="row">
|
|
129
|
+
<div>
|
|
130
|
+
<label for="className">Class name (exact)</label>
|
|
131
|
+
<input type="text" id="className" placeholder="e.g. YTPlayerViewController" />
|
|
132
|
+
</div>
|
|
133
|
+
<div>
|
|
134
|
+
<label for="classSearch">Search (regex)</label>
|
|
135
|
+
<input type="text" id="classSearch" placeholder="e.g. ^YT" />
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div id="fieldsStrings" class="row hidden">
|
|
140
|
+
<div>
|
|
141
|
+
<label for="minLen">Min length</label>
|
|
142
|
+
<input type="number" id="minLen" value="4" min="1" />
|
|
143
|
+
</div>
|
|
144
|
+
<div>
|
|
145
|
+
<label for="stringsSearch">Search (regex)</label>
|
|
146
|
+
<input type="text" id="stringsSearch" placeholder="e.g. https?://" />
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<button id="runBtn">Run</button>
|
|
151
|
+
<div class="status error" id="status"></div>
|
|
152
|
+
<pre id="output" class="hidden"></pre>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<script>
|
|
156
|
+
const action = document.getElementById("action");
|
|
157
|
+
const fieldsSingle = document.getElementById("fieldsSingle");
|
|
158
|
+
const fieldsDiff = document.getElementById("fieldsDiff");
|
|
159
|
+
const fieldsClassdump = document.getElementById("fieldsClassdump");
|
|
160
|
+
const fieldsStrings = document.getElementById("fieldsStrings");
|
|
161
|
+
const runBtn = document.getElementById("runBtn");
|
|
162
|
+
const status = document.getElementById("status");
|
|
163
|
+
const output = document.getElementById("output");
|
|
164
|
+
|
|
165
|
+
function updateFields() {
|
|
166
|
+
const a = action.value;
|
|
167
|
+
fieldsSingle.classList.toggle("hidden", a === "diff");
|
|
168
|
+
fieldsDiff.classList.toggle("hidden", a !== "diff");
|
|
169
|
+
fieldsClassdump.classList.toggle("hidden", a !== "classdump");
|
|
170
|
+
fieldsStrings.classList.toggle("hidden", a !== "strings");
|
|
171
|
+
}
|
|
172
|
+
action.addEventListener("change", updateFields);
|
|
173
|
+
updateFields();
|
|
174
|
+
|
|
175
|
+
runBtn.addEventListener("click", async () => {
|
|
176
|
+
status.style.display = "none";
|
|
177
|
+
output.classList.add("hidden");
|
|
178
|
+
const a = action.value;
|
|
179
|
+
const fd = new FormData();
|
|
180
|
+
let url = "/analysis/" + a;
|
|
181
|
+
|
|
182
|
+
if (a === "diff") {
|
|
183
|
+
const oldFile = document.getElementById("fileOld").files[0];
|
|
184
|
+
const newFile = document.getElementById("fileNew").files[0];
|
|
185
|
+
if (!oldFile || !newFile) {
|
|
186
|
+
status.textContent = "Select both IPAs.";
|
|
187
|
+
status.style.display = "block";
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
fd.append("old", oldFile);
|
|
191
|
+
fd.append("new", newFile);
|
|
192
|
+
} else {
|
|
193
|
+
const file = document.getElementById("fileSingle").files[0];
|
|
194
|
+
if (!file) {
|
|
195
|
+
status.textContent = "Select an IPA.";
|
|
196
|
+
status.style.display = "block";
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
fd.append("ipa", file);
|
|
200
|
+
if (a === "classdump") {
|
|
201
|
+
const cn = document.getElementById("className").value.trim();
|
|
202
|
+
const cs = document.getElementById("classSearch").value.trim();
|
|
203
|
+
if (cn) fd.append("class_name", cn);
|
|
204
|
+
if (cs) fd.append("search", cs);
|
|
205
|
+
} else if (a === "strings") {
|
|
206
|
+
fd.append("min_len", document.getElementById("minLen").value || "4");
|
|
207
|
+
const ss = document.getElementById("stringsSearch").value.trim();
|
|
208
|
+
if (ss) fd.append("search", ss);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
runBtn.disabled = true;
|
|
213
|
+
try {
|
|
214
|
+
const res = await fetch(url, { method: "POST", body: fd });
|
|
215
|
+
const data = await res.json();
|
|
216
|
+
if (!res.ok) {
|
|
217
|
+
status.textContent = data.error || "Request failed.";
|
|
218
|
+
status.style.display = "block";
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
output.textContent = data.text || "(empty)";
|
|
222
|
+
output.classList.remove("hidden");
|
|
223
|
+
} finally {
|
|
224
|
+
runBtn.disabled = false;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
</script>
|
|
228
|
+
</body>
|
|
229
|
+
</html>
|
ipa_forge/gui/app.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Local web GUI wrapping the same ipa_forge.pipeline used by the CLI.
|
|
3
|
+
|
|
4
|
+
Novice flow: pick an IPA file -> the GUI auto-detects the app and the
|
|
5
|
+
matching patch set, warns (non-blocking) if the patch set targets a
|
|
6
|
+
different version, then one "Patch" button produces an unsigned IPA for
|
|
7
|
+
AltStore. No signing identity, no profiles, no YAML editing required.
|
|
8
|
+
|
|
9
|
+
Single-user, local-only: request state lives in module-level dicts. The page
|
|
10
|
+
is a self-contained static template (gui/index.html) with an inline fetch
|
|
11
|
+
flow — no build step, works offline.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import shutil
|
|
18
|
+
import tempfile
|
|
19
|
+
import uuid
|
|
20
|
+
from importlib.metadata import PackageNotFoundError
|
|
21
|
+
from importlib.metadata import version as _installed_version
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from fastapi import FastAPI, File, Form, UploadFile
|
|
25
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
26
|
+
|
|
27
|
+
from ipa_forge.analysis.classdump import render_analysis
|
|
28
|
+
from ipa_forge.analysis.diff import diff_analyses, render_diff
|
|
29
|
+
from ipa_forge.analysis.security import analyze_security, render_security_posture
|
|
30
|
+
from ipa_forge.analysis.strings import strings_in_bundle
|
|
31
|
+
from ipa_forge.bundle.ipa import load_bundle, validate_and_extract
|
|
32
|
+
from ipa_forge.machO.objc import analyze_bundle
|
|
33
|
+
from ipa_forge.patches import find_patch_sets_for_bundle
|
|
34
|
+
from ipa_forge.pipeline import PipelineError, run_pipeline
|
|
35
|
+
from ipa_forge.validators.ipa_validator import IpaValidationError
|
|
36
|
+
|
|
37
|
+
app = FastAPI(title="ipa-forge")
|
|
38
|
+
|
|
39
|
+
_WORK_DIR = Path(tempfile.mkdtemp(prefix="ipa_forge_gui_"))
|
|
40
|
+
_outputs: dict[str, tuple[Path, str]] = {}
|
|
41
|
+
_OUTPUTS_MAX = 20
|
|
42
|
+
# GUI textarea sanity cap -- the CLI (forge analysis strings) has no limit
|
|
43
|
+
# and pipes to grep instead; a browser tab rendering a multi-million-line
|
|
44
|
+
# response is a different failure mode worth guarding against here only.
|
|
45
|
+
_STRINGS_GUI_LIMIT = 2000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _package_version() -> str:
|
|
49
|
+
try:
|
|
50
|
+
return _installed_version("ipa-forge")
|
|
51
|
+
except PackageNotFoundError:
|
|
52
|
+
return "0.1.0"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_FORM_HTML = (
|
|
56
|
+
(Path(__file__).parent / "index.html").read_text(encoding="utf-8").replace("__VERSION__", _package_version())
|
|
57
|
+
)
|
|
58
|
+
_ANALYSIS_HTML = (
|
|
59
|
+
(Path(__file__).parent / "analysis.html").read_text(encoding="utf-8").replace("__VERSION__", _package_version())
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.get("/", response_class=HTMLResponse)
|
|
64
|
+
def index() -> str:
|
|
65
|
+
return _FORM_HTML
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.get("/analysis", response_class=HTMLResponse)
|
|
69
|
+
def analysis_page() -> str:
|
|
70
|
+
return _ANALYSIS_HTML
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.post("/detect")
|
|
74
|
+
async def detect(ipa: UploadFile = File(...)) -> JSONResponse:
|
|
75
|
+
"""Analyze an uploaded IPA: bundle id, version, matching patch sets, and
|
|
76
|
+
a version-mismatch warning (informational — patching is still allowed)."""
|
|
77
|
+
request_dir = _WORK_DIR / uuid.uuid4().hex
|
|
78
|
+
request_dir.mkdir(parents=True)
|
|
79
|
+
try:
|
|
80
|
+
ipa_path = request_dir / Path(ipa.filename or "upload.ipa").name
|
|
81
|
+
ipa_path.write_bytes(await ipa.read())
|
|
82
|
+
try:
|
|
83
|
+
app_path = validate_and_extract(ipa_path, request_dir / "extracted")
|
|
84
|
+
bundle = load_bundle(app_path)
|
|
85
|
+
except (ValueError, IpaValidationError) as e:
|
|
86
|
+
return JSONResponse(status_code=400, content={"error": str(e)})
|
|
87
|
+
|
|
88
|
+
patch_sets = []
|
|
89
|
+
for ps in find_patch_sets_for_bundle(bundle.bundle_id):
|
|
90
|
+
match = ps.version_exact is None or ps.version_exact == bundle.version
|
|
91
|
+
patch_sets.append(
|
|
92
|
+
{
|
|
93
|
+
"name": ps.name,
|
|
94
|
+
"target_version": ps.version_exact or ps.version_spec,
|
|
95
|
+
"matches": match,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return JSONResponse(
|
|
99
|
+
{
|
|
100
|
+
"bundle_id": bundle.bundle_id,
|
|
101
|
+
"version": bundle.version,
|
|
102
|
+
"build": bundle.build,
|
|
103
|
+
"patch_sets": patch_sets,
|
|
104
|
+
# a warning when the app has a patch set but the version differs
|
|
105
|
+
"mismatch": bool(patch_sets) and not any(p["matches"] for p in patch_sets),
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
finally:
|
|
109
|
+
shutil.rmtree(request_dir, ignore_errors=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.post("/patch")
|
|
113
|
+
async def patch(
|
|
114
|
+
ipa: UploadFile = File(...),
|
|
115
|
+
patch_set: str = Form(...),
|
|
116
|
+
identity: str | None = Form(None),
|
|
117
|
+
profile: list[UploadFile] | None = File(None),
|
|
118
|
+
dry_run: bool = Form(False),
|
|
119
|
+
no_sign: bool = Form(True),
|
|
120
|
+
) -> JSONResponse:
|
|
121
|
+
"""Patch an IPA with a discovered patch set. Defaults to unsigned output
|
|
122
|
+
for AltStore (no identity/profile needed); the version-mismatch warning
|
|
123
|
+
is non-blocking — the hooks gate still guards what attaches."""
|
|
124
|
+
request_dir = _WORK_DIR / uuid.uuid4().hex
|
|
125
|
+
request_dir.mkdir(parents=True)
|
|
126
|
+
try:
|
|
127
|
+
ipa_path = request_dir / Path(ipa.filename or "upload.ipa").name
|
|
128
|
+
ipa_path.write_bytes(await ipa.read())
|
|
129
|
+
|
|
130
|
+
from ipa_forge.patches import discover_patch_sets
|
|
131
|
+
|
|
132
|
+
definition = None
|
|
133
|
+
for ps in discover_patch_sets():
|
|
134
|
+
if ps.name == patch_set:
|
|
135
|
+
definition = ps.definition
|
|
136
|
+
break
|
|
137
|
+
if definition is None:
|
|
138
|
+
return JSONResponse(status_code=400, content={"error": f"unknown patch set '{patch_set}'"})
|
|
139
|
+
|
|
140
|
+
profile_paths: list[Path] = []
|
|
141
|
+
if not dry_run and not no_sign:
|
|
142
|
+
if not identity or not identity.strip():
|
|
143
|
+
return JSONResponse(status_code=400, content={"error": "identity is required for signed output"})
|
|
144
|
+
if not profile:
|
|
145
|
+
return JSONResponse(
|
|
146
|
+
status_code=400, content={"error": "at least one profile is required for signed output"}
|
|
147
|
+
)
|
|
148
|
+
profile_dir = request_dir / "profiles"
|
|
149
|
+
profile_dir.mkdir()
|
|
150
|
+
for i, upload in enumerate(profile or []):
|
|
151
|
+
dest = profile_dir / f"{i}_{Path(upload.filename or 'profile.mobileprovision').name}"
|
|
152
|
+
dest.write_bytes(await upload.read())
|
|
153
|
+
profile_paths.append(dest)
|
|
154
|
+
|
|
155
|
+
output_path = request_dir / f"patched_{ipa_path.name}"
|
|
156
|
+
try:
|
|
157
|
+
result = run_pipeline(
|
|
158
|
+
ipa_path,
|
|
159
|
+
definition,
|
|
160
|
+
identity or "",
|
|
161
|
+
profile_paths,
|
|
162
|
+
output_path,
|
|
163
|
+
dry_run=dry_run,
|
|
164
|
+
no_sign=no_sign,
|
|
165
|
+
allow_version_mismatch=True,
|
|
166
|
+
)
|
|
167
|
+
except PipelineError as e:
|
|
168
|
+
return JSONResponse(status_code=400, content={"error": str(e)})
|
|
169
|
+
|
|
170
|
+
response: dict = {"manifest": json.loads(result.manifest.to_json())}
|
|
171
|
+
if result.output_path is not None:
|
|
172
|
+
token = uuid.uuid4().hex
|
|
173
|
+
final_path = _WORK_DIR / f"{token}.ipa"
|
|
174
|
+
result.output_path.replace(final_path)
|
|
175
|
+
_outputs[token] = (final_path, f"patched_{ipa_path.name}")
|
|
176
|
+
_evict_outputs()
|
|
177
|
+
response["download_url"] = f"/download/{token}"
|
|
178
|
+
return JSONResponse(response)
|
|
179
|
+
finally:
|
|
180
|
+
shutil.rmtree(request_dir, ignore_errors=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _evict_outputs() -> None:
|
|
184
|
+
while len(_outputs) > _OUTPUTS_MAX:
|
|
185
|
+
token = next(iter(_outputs))
|
|
186
|
+
path, _ = _outputs.pop(token)
|
|
187
|
+
path.unlink(missing_ok=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.get("/download/{token}", response_model=None)
|
|
191
|
+
def download(token: str) -> FileResponse | JSONResponse:
|
|
192
|
+
entry = _outputs.get(token)
|
|
193
|
+
if entry is None:
|
|
194
|
+
return JSONResponse(status_code=404, content={"error": "not found"})
|
|
195
|
+
path, display_name = entry
|
|
196
|
+
if not path.is_file():
|
|
197
|
+
return JSONResponse(status_code=404, content={"error": "not found"})
|
|
198
|
+
return FileResponse(path, filename=display_name)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _extract_uploaded(request_dir: Path, ipa: UploadFile, name: str = "upload.ipa") -> Path | JSONResponse:
|
|
202
|
+
"""Save + extract an uploaded IPA; returns the app path or a 400
|
|
203
|
+
JSONResponse on a structural/plist error, for the analysis endpoints
|
|
204
|
+
below to return directly."""
|
|
205
|
+
ipa_path = request_dir / Path(ipa.filename or name).name
|
|
206
|
+
ipa_path.write_bytes(await ipa.read())
|
|
207
|
+
try:
|
|
208
|
+
return validate_and_extract(ipa_path, request_dir / "extracted")
|
|
209
|
+
except (ValueError, IpaValidationError) as e:
|
|
210
|
+
return JSONResponse(status_code=400, content={"error": str(e)})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.post("/analysis/classdump")
|
|
214
|
+
async def analysis_classdump(
|
|
215
|
+
ipa: UploadFile = File(...),
|
|
216
|
+
class_name: str | None = Form(None),
|
|
217
|
+
search: str | None = Form(None),
|
|
218
|
+
) -> JSONResponse:
|
|
219
|
+
"""Read-only Objective-C class-dump view -- same engine as
|
|
220
|
+
`forge analysis classdump`, browsable without installing the CLI."""
|
|
221
|
+
request_dir = _WORK_DIR / uuid.uuid4().hex
|
|
222
|
+
request_dir.mkdir(parents=True)
|
|
223
|
+
try:
|
|
224
|
+
app_path = await _extract_uploaded(request_dir, ipa)
|
|
225
|
+
if isinstance(app_path, JSONResponse):
|
|
226
|
+
return app_path
|
|
227
|
+
analysis = analyze_bundle(load_bundle(app_path))
|
|
228
|
+
text = render_analysis(analysis, class_filter=class_name or None, search=search or None)
|
|
229
|
+
return JSONResponse({"text": text or "no matching classes/protocols/categories found"})
|
|
230
|
+
finally:
|
|
231
|
+
shutil.rmtree(request_dir, ignore_errors=True)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.post("/analysis/strings")
|
|
235
|
+
async def analysis_strings(
|
|
236
|
+
ipa: UploadFile = File(...),
|
|
237
|
+
min_len: int = Form(4),
|
|
238
|
+
search: str | None = Form(None),
|
|
239
|
+
) -> JSONResponse:
|
|
240
|
+
"""Printable-string extraction across every executable in the bundle,
|
|
241
|
+
capped at `_STRINGS_GUI_LIMIT` matches for the browser (the CLI has no
|
|
242
|
+
such cap)."""
|
|
243
|
+
request_dir = _WORK_DIR / uuid.uuid4().hex
|
|
244
|
+
request_dir.mkdir(parents=True)
|
|
245
|
+
try:
|
|
246
|
+
app_path = await _extract_uploaded(request_dir, ipa)
|
|
247
|
+
if isinstance(app_path, JSONResponse):
|
|
248
|
+
return app_path
|
|
249
|
+
found = strings_in_bundle(load_bundle(app_path), min_len=min_len)
|
|
250
|
+
if search:
|
|
251
|
+
import re
|
|
252
|
+
|
|
253
|
+
pat = re.compile(search)
|
|
254
|
+
found = [s for s in found if pat.search(s.value)]
|
|
255
|
+
total = len(found)
|
|
256
|
+
found = found[:_STRINGS_GUI_LIMIT]
|
|
257
|
+
text = "\n".join(f"[{s.binary}] {s.value}" for s in found)
|
|
258
|
+
if total > len(found):
|
|
259
|
+
text += f"\n… truncated ({total} total; use the CLI for the full list)"
|
|
260
|
+
return JSONResponse({"text": text or "no strings found"})
|
|
261
|
+
finally:
|
|
262
|
+
shutil.rmtree(request_dir, ignore_errors=True)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.post("/analysis/security")
|
|
266
|
+
async def analysis_security(ipa: UploadFile = File(...)) -> JSONResponse:
|
|
267
|
+
"""PIE/encryption-flag/stack-protector/ARC posture of the main
|
|
268
|
+
executable -- detection only, this never decrypts anything."""
|
|
269
|
+
request_dir = _WORK_DIR / uuid.uuid4().hex
|
|
270
|
+
request_dir.mkdir(parents=True)
|
|
271
|
+
try:
|
|
272
|
+
app_path = await _extract_uploaded(request_dir, ipa)
|
|
273
|
+
if isinstance(app_path, JSONResponse):
|
|
274
|
+
return app_path
|
|
275
|
+
bundle = load_bundle(app_path)
|
|
276
|
+
posture = analyze_security(bundle.root / bundle.main_executable_name)
|
|
277
|
+
return JSONResponse({"text": render_security_posture(posture)})
|
|
278
|
+
finally:
|
|
279
|
+
shutil.rmtree(request_dir, ignore_errors=True)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.post("/analysis/diff")
|
|
283
|
+
async def analysis_diff(old: UploadFile = File(...), new: UploadFile = File(...)) -> JSONResponse:
|
|
284
|
+
"""Version-to-version survey between two uploaded builds -- same engine
|
|
285
|
+
as `forge analysis diff`."""
|
|
286
|
+
request_dir = _WORK_DIR / uuid.uuid4().hex
|
|
287
|
+
(request_dir / "old").mkdir(parents=True)
|
|
288
|
+
(request_dir / "new").mkdir(parents=True)
|
|
289
|
+
try:
|
|
290
|
+
old_path = await _extract_uploaded(request_dir / "old", old, "old.ipa")
|
|
291
|
+
if isinstance(old_path, JSONResponse):
|
|
292
|
+
return old_path
|
|
293
|
+
new_path = await _extract_uploaded(request_dir / "new", new, "new.ipa")
|
|
294
|
+
if isinstance(new_path, JSONResponse):
|
|
295
|
+
return new_path
|
|
296
|
+
bundle_old = load_bundle(old_path)
|
|
297
|
+
bundle_new = load_bundle(new_path)
|
|
298
|
+
diff = diff_analyses(
|
|
299
|
+
analyze_bundle(bundle_old), analyze_bundle(bundle_new), bundle_old.info_plist, bundle_new.info_plist
|
|
300
|
+
)
|
|
301
|
+
header = f"{bundle_old.bundle_id} {bundle_old.version} -> {bundle_new.version}\n"
|
|
302
|
+
return JSONResponse({"text": header + render_diff(diff)})
|
|
303
|
+
finally:
|
|
304
|
+
shutil.rmtree(request_dir, ignore_errors=True)
|