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,96 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Bottom-up nested-executable inventory walker.
|
|
3
|
+
|
|
4
|
+
Classification is purely structural (by containing directory suffix), never
|
|
5
|
+
by bundle/app identifier -- this keeps the core engine app-agnostic per the
|
|
6
|
+
architecture's core rule.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ipa_forge.bundle.models import AppBundle, MachOKind, MachOTarget
|
|
14
|
+
from ipa_forge.bundle.plist import read_plist
|
|
15
|
+
from ipa_forge.machO.detect import is_macho_file
|
|
16
|
+
|
|
17
|
+
_OWNING_BUNDLE_SUFFIX: dict[MachOKind, str] = {"appex": ".appex", "watch_app": ".app"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _classify(rel: Path, main_executable_name: str) -> MachOKind:
|
|
21
|
+
parts = rel.parts
|
|
22
|
+
|
|
23
|
+
if len(parts) == 1 and parts[0] == main_executable_name:
|
|
24
|
+
return "main"
|
|
25
|
+
|
|
26
|
+
# Innermost containing directory determines kind: a dylib inside a
|
|
27
|
+
# .framework is still a "framework" executable, a framework nested
|
|
28
|
+
# inside a watch app is still a "framework", etc.
|
|
29
|
+
for name in reversed(parts[:-1]):
|
|
30
|
+
if name.endswith(".appex"):
|
|
31
|
+
return "appex"
|
|
32
|
+
if name.endswith(".app"):
|
|
33
|
+
return "watch_app"
|
|
34
|
+
if name.endswith(".framework"):
|
|
35
|
+
return "framework"
|
|
36
|
+
|
|
37
|
+
if rel.suffix == ".dylib":
|
|
38
|
+
return "dylib"
|
|
39
|
+
|
|
40
|
+
return "other"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _owning_bundle_dir(app_root: Path, rel: Path, kind: MachOKind) -> Path | None:
|
|
44
|
+
"""Directory of the bundle (.appex/.app) that owns this target's own
|
|
45
|
+
Info.plist, for kinds that can carry their own provisioning profile."""
|
|
46
|
+
suffix = _OWNING_BUNDLE_SUFFIX.get(kind)
|
|
47
|
+
if suffix is None:
|
|
48
|
+
return None
|
|
49
|
+
parts = rel.parts
|
|
50
|
+
for i in range(len(parts) - 1, -1, -1):
|
|
51
|
+
if parts[i].endswith(suffix):
|
|
52
|
+
return app_root.joinpath(*parts[: i + 1])
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_bundle_id(bundle_dir: Path) -> str | None:
|
|
57
|
+
try:
|
|
58
|
+
plist = read_plist(bundle_dir / "Info.plist")
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
return None
|
|
61
|
+
bundle_id = plist.get("CFBundleIdentifier")
|
|
62
|
+
return bundle_id if isinstance(bundle_id, str) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_inventory(bundle: AppBundle) -> list[MachOTarget]:
|
|
66
|
+
"""Walk bundle.root for Mach-O executables, returned in bottom-up sign order
|
|
67
|
+
(deepest-nested first; the top-level main executable always last)."""
|
|
68
|
+
app_root = bundle.root
|
|
69
|
+
main_name = bundle.main_executable_name
|
|
70
|
+
|
|
71
|
+
targets: list[MachOTarget] = []
|
|
72
|
+
for path in app_root.rglob("*"):
|
|
73
|
+
if not is_macho_file(path):
|
|
74
|
+
continue
|
|
75
|
+
rel = path.relative_to(app_root)
|
|
76
|
+
kind = _classify(rel, main_name)
|
|
77
|
+
|
|
78
|
+
target_bundle_id: str | None
|
|
79
|
+
if kind == "main":
|
|
80
|
+
target_bundle_id = bundle.bundle_id
|
|
81
|
+
else:
|
|
82
|
+
owning_dir = _owning_bundle_dir(app_root, rel, kind)
|
|
83
|
+
target_bundle_id = _read_bundle_id(owning_dir) if owning_dir else None
|
|
84
|
+
|
|
85
|
+
targets.append(
|
|
86
|
+
MachOTarget(
|
|
87
|
+
path=path,
|
|
88
|
+
bundle_relative=str(Path("Payload") / app_root.name / rel),
|
|
89
|
+
kind=kind,
|
|
90
|
+
depth=len(rel.parts),
|
|
91
|
+
bundle_id=target_bundle_id,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
targets.sort(key=lambda t: (t.kind == "main", -t.depth))
|
|
96
|
+
return targets
|
ipa_forge/bundle/ipa.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""IPA extraction and repackaging.
|
|
3
|
+
|
|
4
|
+
Uses the platform `zip`/`unzip` CLI tools rather than Python's `zipfile`
|
|
5
|
+
module because Apple bundles routinely contain symlinks (e.g.
|
|
6
|
+
`Foo.framework/Foo -> Versions/Current/Foo`), and `zip -y` / `unzip` round-trip
|
|
7
|
+
those correctly while `zipfile` does not preserve them by default.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ipa_forge.bundle.inventory import build_inventory
|
|
16
|
+
from ipa_forge.bundle.models import AppBundle
|
|
17
|
+
from ipa_forge.bundle.plist import read_plist
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_ipa(ipa_path: Path, dest_dir: Path) -> Path:
|
|
21
|
+
"""Extract ipa_path into dest_dir. Returns the path to Payload/<App>.app.
|
|
22
|
+
|
|
23
|
+
Structural validation of the archive itself is a separate stage
|
|
24
|
+
(validators/ipa_validator.py) run by the pipeline before this is called;
|
|
25
|
+
here we only require the extraction to yield exactly one Payload/*.app.
|
|
26
|
+
"""
|
|
27
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
subprocess.run(
|
|
29
|
+
["unzip", "-qq", "-o", str(ipa_path), "-d", str(dest_dir)],
|
|
30
|
+
check=True,
|
|
31
|
+
)
|
|
32
|
+
payload_dir = dest_dir / "Payload"
|
|
33
|
+
apps = [p for p in payload_dir.iterdir() if p.is_dir() and p.name.endswith(".app")]
|
|
34
|
+
if len(apps) != 1:
|
|
35
|
+
raise FileNotFoundError(f"expected exactly one Payload/*.app bundle under {payload_dir}, found {len(apps)}")
|
|
36
|
+
return apps[0]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def repack_ipa(extraction_root: Path, output_path: Path) -> Path:
|
|
40
|
+
"""Zip extraction_root/Payload back into a standard-structure .ipa at output_path."""
|
|
41
|
+
payload_dir = extraction_root / "Payload"
|
|
42
|
+
if not payload_dir.is_dir():
|
|
43
|
+
raise FileNotFoundError(f"No Payload/ directory under {extraction_root}")
|
|
44
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
if output_path.exists():
|
|
46
|
+
output_path.unlink()
|
|
47
|
+
subprocess.run(
|
|
48
|
+
["zip", "-qq", "-r", "-y", str(output_path), "Payload"],
|
|
49
|
+
cwd=extraction_root,
|
|
50
|
+
check=True,
|
|
51
|
+
)
|
|
52
|
+
return output_path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_and_extract(ipa: Path, dest: Path) -> Path:
|
|
56
|
+
"""Structure-validate (pipeline stage 1), then extract. Raises ValueError
|
|
57
|
+
on malformed input; the neutral home shared by the GUI and CLI."""
|
|
58
|
+
from ipa_forge.validators.ipa_validator import IpaValidationError, validate_ipa_structure
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
validate_ipa_structure(ipa)
|
|
62
|
+
except IpaValidationError as e:
|
|
63
|
+
raise ValueError(str(e)) from e
|
|
64
|
+
return extract_ipa(ipa, dest)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_bundle(app_path: Path) -> AppBundle:
|
|
68
|
+
"""Build an AppBundle model (with full inventory) from an already-extracted .app."""
|
|
69
|
+
info_plist_path = app_path / "Info.plist"
|
|
70
|
+
info_plist = read_plist(info_plist_path)
|
|
71
|
+
|
|
72
|
+
bundle = AppBundle(
|
|
73
|
+
root=app_path,
|
|
74
|
+
extraction_root=app_path.parent.parent,
|
|
75
|
+
info_plist=info_plist,
|
|
76
|
+
bundle_id=info_plist["CFBundleIdentifier"],
|
|
77
|
+
version=info_plist.get("CFBundleShortVersionString", ""),
|
|
78
|
+
build=info_plist.get("CFBundleVersion", ""),
|
|
79
|
+
)
|
|
80
|
+
bundle.executables = build_inventory(bundle)
|
|
81
|
+
return bundle
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Core bundle data model shared by the patch engine and the signing subsystem."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
MachOKind = Literal["main", "framework", "dylib", "appex", "watch_app", "other"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MachOTarget:
|
|
15
|
+
"""A single executable within the bundle (main binary, framework, dylib, extension, watch app)."""
|
|
16
|
+
|
|
17
|
+
path: Path
|
|
18
|
+
"""Absolute path on disk, inside the extraction working directory."""
|
|
19
|
+
|
|
20
|
+
bundle_relative: str
|
|
21
|
+
"""Path relative to the extraction root (e.g. "Payload/App.app/Frameworks/Foo.framework/Foo"),
|
|
22
|
+
used to address this target from patch definitions and the manifest."""
|
|
23
|
+
|
|
24
|
+
kind: MachOKind
|
|
25
|
+
|
|
26
|
+
depth: int
|
|
27
|
+
"""Number of path components below the top-level app root; used to derive bottom-up sign order."""
|
|
28
|
+
|
|
29
|
+
bundle_id: str | None = None
|
|
30
|
+
"""CFBundleIdentifier of the owning bundle, for kinds that have their own Info.plist and can
|
|
31
|
+
carry their own embedded.mobileprovision (main, appex, watch_app). None for framework/dylib/other,
|
|
32
|
+
which are signed but never embed a profile of their own."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class AppBundle:
|
|
37
|
+
"""In-memory representation of an extracted .app, with its nested executable inventory."""
|
|
38
|
+
|
|
39
|
+
root: Path
|
|
40
|
+
"""Path to Payload/<App>.app."""
|
|
41
|
+
|
|
42
|
+
extraction_root: Path
|
|
43
|
+
"""Path to the temp directory containing Payload/, i.e. root.parent.parent."""
|
|
44
|
+
|
|
45
|
+
info_plist: dict[str, Any]
|
|
46
|
+
bundle_id: str
|
|
47
|
+
version: str
|
|
48
|
+
build: str
|
|
49
|
+
|
|
50
|
+
executables: list[MachOTarget] = field(default_factory=list)
|
|
51
|
+
"""Populated by the inventory walker, ordered bottom-up (deepest-nested first, main app last)."""
|
|
52
|
+
|
|
53
|
+
entitlements: dict[str, Any] | None = None
|
|
54
|
+
"""Populated during the signing stage; None until then."""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def main_executable_name(self) -> str:
|
|
58
|
+
return self.info_plist["CFBundleExecutable"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Info.plist read/write helpers (binary or XML plist, transparently)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import plistlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_plist(path: Path) -> dict[str, Any]:
|
|
12
|
+
with open(path, "rb") as f:
|
|
13
|
+
return plistlib.load(f)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_plist(path: Path, data: dict[str, Any]) -> None:
|
|
17
|
+
with open(path, "wb") as f:
|
|
18
|
+
plistlib.dump(data, f)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""`forge analysis` subcommands: general-purpose reverse engineering of an
|
|
3
|
+
IPA, built on the same Mach-O/ObjC analysis engine `forge hooks` uses for
|
|
4
|
+
hook verification (see `ipa_forge.machO.objc`).
|
|
5
|
+
|
|
6
|
+
Every command accepts ``--app-dir`` in place of the IPA, same as
|
|
7
|
+
`forge hooks` — point it at an already-extracted ``Payload/<App>.app``
|
|
8
|
+
directory to skip re-extraction when iterating.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re as _re
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from ipa_forge.analysis.classdump import render_analysis
|
|
20
|
+
from ipa_forge.analysis.diff import diff_analyses, render_diff
|
|
21
|
+
from ipa_forge.analysis.security import analyze_security, render_security_posture
|
|
22
|
+
from ipa_forge.analysis.strings import strings_in_bundle
|
|
23
|
+
from ipa_forge.analysis.symbols import analyze_symbols
|
|
24
|
+
from ipa_forge.bundle.ipa import load_bundle
|
|
25
|
+
from ipa_forge.bundle.models import AppBundle
|
|
26
|
+
from ipa_forge.cli.common import extract_or_use, resolve_app_path
|
|
27
|
+
from ipa_forge.machO.arch import AmbiguousArchError, ArchNotFoundError, NotMachOError
|
|
28
|
+
from ipa_forge.machO.detect import bundle_executable_paths
|
|
29
|
+
from ipa_forge.machO.objc import analyze_bundle
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(help="General-purpose IPA reverse engineering: class-dump, strings, symbols, security, diff.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _select_binary(bundle: AppBundle, binary: str | None) -> Path:
|
|
35
|
+
"""Resolve --binary to one executable path: the main executable when
|
|
36
|
+
omitted, otherwise the unique path whose bundle-relative string contains
|
|
37
|
+
the given substring."""
|
|
38
|
+
if binary is None:
|
|
39
|
+
return bundle.root / bundle.main_executable_name
|
|
40
|
+
matches = [p for p in bundle_executable_paths(bundle) if binary in str(p)]
|
|
41
|
+
if not matches:
|
|
42
|
+
typer.secho(f"error: no executable matching '{binary}' found in the bundle", fg=typer.colors.RED, err=True)
|
|
43
|
+
raise typer.Exit(code=1)
|
|
44
|
+
if len(matches) > 1:
|
|
45
|
+
typer.secho(
|
|
46
|
+
f"error: '{binary}' matches multiple executables: {[str(p) for p in matches]} -- be more specific",
|
|
47
|
+
fg=typer.colors.RED,
|
|
48
|
+
err=True,
|
|
49
|
+
)
|
|
50
|
+
raise typer.Exit(code=1)
|
|
51
|
+
return matches[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("classdump")
|
|
55
|
+
def analysis_classdump(
|
|
56
|
+
ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
|
|
57
|
+
app_dir: Path | None = typer.Option(
|
|
58
|
+
None,
|
|
59
|
+
"--app-dir",
|
|
60
|
+
exists=True,
|
|
61
|
+
help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
|
|
62
|
+
),
|
|
63
|
+
class_name: str | None = typer.Option(None, "--class", help="Restrict to one class"),
|
|
64
|
+
search: str | None = typer.Option(None, "--search", help="Only classes whose name matches this regex"),
|
|
65
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write to a file instead of stdout"),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Dump the app's Objective-C runtime metadata as `.h`-style class-dump
|
|
68
|
+
text: every class (superclass, protocol conformance, ivars, properties,
|
|
69
|
+
method signatures), protocol declarations, and categories. `--class`/
|
|
70
|
+
`--search` restrict output to matching classes only (protocols and
|
|
71
|
+
categories are omitted in that case, matching `forge hooks extract`)."""
|
|
72
|
+
with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
|
|
73
|
+
app_path = extract_or_use(ipa, app_dir, Path(tmp))
|
|
74
|
+
bundle = load_bundle(app_path)
|
|
75
|
+
analysis = analyze_bundle(bundle)
|
|
76
|
+
|
|
77
|
+
if class_name and class_name not in analysis.classes:
|
|
78
|
+
typer.secho(f"class '{class_name}' not found", fg=typer.colors.YELLOW)
|
|
79
|
+
raise typer.Exit(code=1)
|
|
80
|
+
|
|
81
|
+
text = render_analysis(analysis, class_filter=class_name, search=search)
|
|
82
|
+
if not text:
|
|
83
|
+
typer.echo("no matching classes/protocols/categories found")
|
|
84
|
+
raise typer.Exit(code=1)
|
|
85
|
+
|
|
86
|
+
if output:
|
|
87
|
+
output.write_text(text + "\n")
|
|
88
|
+
typer.echo(f"wrote {output}")
|
|
89
|
+
else:
|
|
90
|
+
typer.echo(text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("strings")
|
|
94
|
+
def analysis_strings(
|
|
95
|
+
ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
|
|
96
|
+
app_dir: Path | None = typer.Option(
|
|
97
|
+
None,
|
|
98
|
+
"--app-dir",
|
|
99
|
+
exists=True,
|
|
100
|
+
help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
|
|
101
|
+
),
|
|
102
|
+
min_len: int = typer.Option(4, "--min-len", help="Minimum printable-ASCII run length to report"),
|
|
103
|
+
search: str | None = typer.Option(None, "--search", help="Only strings matching this regex"),
|
|
104
|
+
binary: str | None = typer.Option(None, "--binary", help="Restrict to one executable (bundle-relative substring)"),
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Extract printable strings from every executable in the bundle (main +
|
|
107
|
+
frameworks + dylibs + extensions), tagged with which binary each string
|
|
108
|
+
came from. Pipe to grep for anything not covered by --search."""
|
|
109
|
+
with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
|
|
110
|
+
app_path = extract_or_use(ipa, app_dir, Path(tmp))
|
|
111
|
+
bundle = load_bundle(app_path)
|
|
112
|
+
found = strings_in_bundle(bundle, min_len=min_len)
|
|
113
|
+
|
|
114
|
+
if binary:
|
|
115
|
+
found = [s for s in found if binary in s.binary]
|
|
116
|
+
if search:
|
|
117
|
+
pat = _re.compile(search)
|
|
118
|
+
found = [s for s in found if pat.search(s.value)]
|
|
119
|
+
|
|
120
|
+
for s in found:
|
|
121
|
+
typer.echo(f"[{s.binary}] {s.value}")
|
|
122
|
+
typer.echo(f"-- {len(found)} string(s)", err=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command("symbols")
|
|
126
|
+
def analysis_symbols(
|
|
127
|
+
ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
|
|
128
|
+
app_dir: Path | None = typer.Option(
|
|
129
|
+
None,
|
|
130
|
+
"--app-dir",
|
|
131
|
+
exists=True,
|
|
132
|
+
help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
|
|
133
|
+
),
|
|
134
|
+
binary: str | None = typer.Option(None, "--binary", help="Which executable to inspect; default: main executable"),
|
|
135
|
+
arch: str | None = typer.Option(None, "--arch", help="Architecture slice, required for a universal binary"),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Linked libraries and imported/exported symbols for one Mach-O
|
|
138
|
+
executable in the bundle (`otool -L` + `nm`, structured)."""
|
|
139
|
+
with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
|
|
140
|
+
app_path = extract_or_use(ipa, app_dir, Path(tmp))
|
|
141
|
+
bundle = load_bundle(app_path)
|
|
142
|
+
target = _select_binary(bundle, binary)
|
|
143
|
+
try:
|
|
144
|
+
result = analyze_symbols(target, arch)
|
|
145
|
+
except (AmbiguousArchError, ArchNotFoundError, NotMachOError) as e:
|
|
146
|
+
typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
|
|
147
|
+
raise typer.Exit(code=1) from None
|
|
148
|
+
|
|
149
|
+
typer.echo(f"{result.binary} ({result.arch})")
|
|
150
|
+
typer.echo(f"linked libraries ({len(result.linked_libraries)}):")
|
|
151
|
+
for lib in result.linked_libraries:
|
|
152
|
+
typer.echo(f" {lib}")
|
|
153
|
+
typer.echo(f"imported symbols ({len(result.imported_symbols)}):")
|
|
154
|
+
for sym in result.imported_symbols:
|
|
155
|
+
typer.echo(f" {sym}")
|
|
156
|
+
typer.echo(f"exported symbols ({len(result.exported_symbols)}):")
|
|
157
|
+
for sym in result.exported_symbols:
|
|
158
|
+
typer.echo(f" {sym}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command("security")
|
|
162
|
+
def analysis_security(
|
|
163
|
+
ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
|
|
164
|
+
app_dir: Path | None = typer.Option(
|
|
165
|
+
None,
|
|
166
|
+
"--app-dir",
|
|
167
|
+
exists=True,
|
|
168
|
+
help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
|
|
169
|
+
),
|
|
170
|
+
binary: str | None = typer.Option(None, "--binary", help="Which executable to inspect; default: main executable"),
|
|
171
|
+
arch: str | None = typer.Option(None, "--arch", help="Architecture slice, required for a universal binary"),
|
|
172
|
+
) -> None:
|
|
173
|
+
"""PIE, encryption-flag (detection only, never decrypted), stack
|
|
174
|
+
protector, an ARC heuristic, min-OS, and platform for one executable."""
|
|
175
|
+
with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
|
|
176
|
+
app_path = extract_or_use(ipa, app_dir, Path(tmp))
|
|
177
|
+
bundle = load_bundle(app_path)
|
|
178
|
+
target = _select_binary(bundle, binary)
|
|
179
|
+
try:
|
|
180
|
+
posture = analyze_security(target, arch)
|
|
181
|
+
except (AmbiguousArchError, ArchNotFoundError, NotMachOError) as e:
|
|
182
|
+
typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
|
|
183
|
+
raise typer.Exit(code=1) from None
|
|
184
|
+
|
|
185
|
+
typer.echo(render_security_posture(posture))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command("diff")
|
|
189
|
+
def analysis_diff(
|
|
190
|
+
old_ipa: Path | None = typer.Option(
|
|
191
|
+
None, "--old", exists=True, help="Previous-version .ipa (not needed with --old-app-dir)"
|
|
192
|
+
),
|
|
193
|
+
new_ipa: Path | None = typer.Option(
|
|
194
|
+
None, "--new", exists=True, help="New-version .ipa (not needed with --new-app-dir)"
|
|
195
|
+
),
|
|
196
|
+
old_app_dir: Path | None = typer.Option(
|
|
197
|
+
None, "--old-app-dir", exists=True, help="Extracted Payload/<App>.app of the old build"
|
|
198
|
+
),
|
|
199
|
+
new_app_dir: Path | None = typer.Option(
|
|
200
|
+
None, "--new-app-dir", exists=True, help="Extracted Payload/<App>.app of the new build"
|
|
201
|
+
),
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Survey what changed between two builds of the same app: classes and
|
|
204
|
+
protocols added/removed, per-class method churn, and Info.plist key
|
|
205
|
+
changes. Broader and purely informational compared to `forge hooks
|
|
206
|
+
diff`, which only re-checks one patch definition's declared hook
|
|
207
|
+
targets and fails the exit code on a required-hook regression."""
|
|
208
|
+
with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
|
|
209
|
+
try:
|
|
210
|
+
old_path = resolve_app_path(old_ipa, old_app_dir, Path(tmp) / "old")
|
|
211
|
+
new_path = resolve_app_path(new_ipa, new_app_dir, Path(tmp) / "new")
|
|
212
|
+
except ValueError as e:
|
|
213
|
+
typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
|
|
214
|
+
raise typer.Exit(code=1) from None
|
|
215
|
+
bundle_old = load_bundle(old_path)
|
|
216
|
+
bundle_new = load_bundle(new_path)
|
|
217
|
+
diff = diff_analyses(
|
|
218
|
+
analyze_bundle(bundle_old),
|
|
219
|
+
analyze_bundle(bundle_new),
|
|
220
|
+
bundle_old.info_plist,
|
|
221
|
+
bundle_new.info_plist,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
typer.echo(f"{bundle_old.bundle_id} {bundle_old.version} -> {bundle_new.version}")
|
|
225
|
+
typer.echo(render_diff(diff))
|
ipa_forge/cli/common.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Shared CLI helpers. The extraction helper lives in the bundle domain
|
|
3
|
+
(ipa_forge.bundle.ipa.validate_and_extract); this module re-exports it so
|
|
4
|
+
CLI code reads cleanly, and provides the --app-dir resolution used by the
|
|
5
|
+
`forge hooks` and `forge analysis` commands to skip re-extraction during
|
|
6
|
+
iteration."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from ipa_forge.bundle.ipa import validate_and_extract as validated_extract # noqa: F401
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_app_path(ipa: Path | None, app_dir: Path | None, dest: Path) -> Path:
|
|
18
|
+
"""Return the app bundle to analyze: the already-extracted ``app_dir``
|
|
19
|
+
when given, otherwise extract ``ipa`` into ``dest``.
|
|
20
|
+
|
|
21
|
+
Exactly one of the two inputs must be set — passing both is a mistake
|
|
22
|
+
(the dir would silently take precedence over a fresh extract) and passing
|
|
23
|
+
neither leaves nothing to analyze.
|
|
24
|
+
"""
|
|
25
|
+
if app_dir is not None and ipa is not None:
|
|
26
|
+
raise ValueError("pass either --ipa or --app-dir, not both")
|
|
27
|
+
if app_dir is not None:
|
|
28
|
+
return app_dir
|
|
29
|
+
if ipa is None:
|
|
30
|
+
raise ValueError("--ipa is required when --app-dir is not given")
|
|
31
|
+
return validated_extract(ipa, dest)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_or_use(ipa: Path | None, app_dir: Path | None, tmp: Path) -> Path:
|
|
35
|
+
"""`resolve_app_path`, with resolution errors reported as a clean
|
|
36
|
+
single-line CLI error instead of a traceback."""
|
|
37
|
+
try:
|
|
38
|
+
return resolve_app_path(ipa, app_dir, tmp)
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
|
|
41
|
+
raise typer.Exit(code=1) from None
|