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.
Files changed (62) hide show
  1. ipa_forge/__init__.py +1 -0
  2. ipa_forge/altstore/__init__.py +1 -0
  3. ipa_forge/altstore/source.py +44 -0
  4. ipa_forge/analysis/__init__.py +18 -0
  5. ipa_forge/analysis/classdump.py +103 -0
  6. ipa_forge/analysis/diff.py +127 -0
  7. ipa_forge/analysis/security.py +72 -0
  8. ipa_forge/analysis/strings.py +40 -0
  9. ipa_forge/analysis/symbols.py +42 -0
  10. ipa_forge/analysis/type_encoding.py +164 -0
  11. ipa_forge/bundle/__init__.py +1 -0
  12. ipa_forge/bundle/inventory.py +96 -0
  13. ipa_forge/bundle/ipa.py +81 -0
  14. ipa_forge/bundle/models.py +58 -0
  15. ipa_forge/bundle/plist.py +18 -0
  16. ipa_forge/cli/__init__.py +1 -0
  17. ipa_forge/cli/analysis.py +225 -0
  18. ipa_forge/cli/common.py +41 -0
  19. ipa_forge/cli/hooks.py +357 -0
  20. ipa_forge/cli/main.py +170 -0
  21. ipa_forge/gui/__init__.py +1 -0
  22. ipa_forge/gui/analysis.html +229 -0
  23. ipa_forge/gui/app.py +304 -0
  24. ipa_forge/gui/index.html +244 -0
  25. ipa_forge/hooks/__init__.py +0 -0
  26. ipa_forge/hooks/scan.py +81 -0
  27. ipa_forge/hooks/verify.py +348 -0
  28. ipa_forge/machO/__init__.py +1 -0
  29. ipa_forge/machO/arch.py +94 -0
  30. ipa_forge/machO/detect.py +50 -0
  31. ipa_forge/machO/injector.py +99 -0
  32. ipa_forge/machO/objc.py +534 -0
  33. ipa_forge/manifest.py +74 -0
  34. ipa_forge/patch/__init__.py +1 -0
  35. ipa_forge/patch/base.py +48 -0
  36. ipa_forge/patch/binary.py +131 -0
  37. ipa_forge/patch/dylib.py +59 -0
  38. ipa_forge/patch/engine.py +48 -0
  39. ipa_forge/patch/loader.py +88 -0
  40. ipa_forge/patch/paths.py +19 -0
  41. ipa_forge/patch/plist.py +61 -0
  42. ipa_forge/patch/resolver.py +37 -0
  43. ipa_forge/patch/resource.py +135 -0
  44. ipa_forge/patch/schema.py +118 -0
  45. ipa_forge/patch/version.py +25 -0
  46. ipa_forge/patches.py +76 -0
  47. ipa_forge/pipeline.py +235 -0
  48. ipa_forge/signing/__init__.py +1 -0
  49. ipa_forge/signing/backend.py +111 -0
  50. ipa_forge/signing/pipeline.py +99 -0
  51. ipa_forge/signing/profile.py +131 -0
  52. ipa_forge/signing/provider.py +113 -0
  53. ipa_forge/signing/reconcile.py +27 -0
  54. ipa_forge/validators/__init__.py +1 -0
  55. ipa_forge/validators/archive_validator.py +25 -0
  56. ipa_forge/validators/bundle_validator.py +27 -0
  57. ipa_forge/validators/ipa_validator.py +39 -0
  58. ipa_forge-0.1.0.dist-info/METADATA +914 -0
  59. ipa_forge-0.1.0.dist-info/RECORD +62 -0
  60. ipa_forge-0.1.0.dist-info/WHEEL +4 -0
  61. ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
  62. ipa_forge-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,94 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Read-only fat/universal Mach-O architecture selection, backed by LIEF."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+
8
+ import lief
9
+
10
+ # Enum members are hashable at runtime (mypy agrees); Pyright's reportUnhashable
11
+ # does not special-case enum members as dict keys (false positive).
12
+ _ARCH_NAMES: dict[lief.MachO.Header.CPU_TYPE, str] = {
13
+ lief.MachO.Header.CPU_TYPE.ARM64: "arm64", # pyright: ignore[reportUnhashable]
14
+ lief.MachO.Header.CPU_TYPE.ARM: "armv7", # pyright: ignore[reportUnhashable]
15
+ lief.MachO.Header.CPU_TYPE.X86_64: "x86_64", # pyright: ignore[reportUnhashable]
16
+ lief.MachO.Header.CPU_TYPE.X86: "i386", # pyright: ignore[reportUnhashable]
17
+ }
18
+
19
+
20
+ class NotMachOError(Exception):
21
+ """Raised when the target file cannot be parsed as Mach-O at all."""
22
+
23
+
24
+ class AmbiguousArchError(Exception):
25
+ """Raised when a fat/universal binary is targeted without an explicit `arch:` selector."""
26
+
27
+
28
+ class ArchNotFoundError(Exception):
29
+ """Raised when an explicit `arch:` selector does not match any slice in the binary."""
30
+
31
+
32
+ def arch_name(binary: lief.MachO.Binary) -> str:
33
+ """Canonical arch name (e.g. "arm64") for a parsed Mach-O slice."""
34
+ return _ARCH_NAMES.get(binary.header.cpu_type, str(binary.header.cpu_type))
35
+
36
+
37
+ def require_slice(fat: lief.MachO.FatBinary, index: int) -> lief.MachO.Binary:
38
+ """LIEF's FatBinary.at() is typed as Optional; callers only use it after a
39
+ non-empty length check, so a None here means the file changed under us."""
40
+ binary = fat.at(index)
41
+ if binary is None:
42
+ raise NotMachOError(f"Mach-O slice {index} unexpectedly missing")
43
+ return binary
44
+
45
+
46
+ def load_macho(path: Path, arch: str | None = None) -> lief.MachO.Binary:
47
+ """Return the single Mach-O slice to operate on.
48
+
49
+ Thin binaries are returned directly. Fat/universal binaries require an
50
+ explicit `arch` selector -- silently picking "the first slice" would make
51
+ patches applied to the wrong architecture on a per-device basis.
52
+ """
53
+ fat = lief.MachO.parse(str(path))
54
+ if fat is None or len(fat) == 0:
55
+ raise NotMachOError(f"{path} is not a valid Mach-O file")
56
+
57
+ if len(fat) == 1:
58
+ return require_slice(fat, 0)
59
+
60
+ available = [arch_name(require_slice(fat, i)) for i in range(len(fat))]
61
+ if arch is None:
62
+ raise AmbiguousArchError(
63
+ f"{path} is a universal binary with architectures {available}; "
64
+ "an explicit `arch:` selector is required in the patch definition"
65
+ )
66
+ for i in range(len(fat)):
67
+ binary = require_slice(fat, i)
68
+ if arch_name(binary) == arch:
69
+ return binary
70
+ raise ArchNotFoundError(f"Architecture '{arch}' not found in {path}; available: {available}")
71
+
72
+
73
+ def available_archs(path: Path) -> list[str]:
74
+ fat = lief.MachO.parse(str(path))
75
+ if fat is None or len(fat) == 0:
76
+ raise NotMachOError(f"{path} is not a valid Mach-O file")
77
+ return [arch_name(require_slice(fat, i)) for i in range(len(fat))]
78
+
79
+
80
+ def slice_byte_range(path: Path, arch: str | None = None) -> tuple[int, int]:
81
+ """Return the (start, end) byte offsets of the selected arch's slice within
82
+ the on-disk file. For thin (non-fat) binaries this is the whole file --
83
+ used to bound binary-pattern search/replace to the correct architecture
84
+ so a patch never silently lands in the wrong slice of a universal binary.
85
+ """
86
+ fat = lief.MachO.parse(str(path))
87
+ if fat is None or len(fat) == 0:
88
+ raise NotMachOError(f"{path} is not a valid Mach-O file")
89
+
90
+ if len(fat) == 1:
91
+ return 0, path.stat().st_size
92
+
93
+ binary = load_macho(path, arch)
94
+ return binary.fat_offset, binary.fat_offset + binary.original_size
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Lightweight Mach-O detection by magic number, used by the bundle inventory walker."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import struct
7
+ from pathlib import Path
8
+
9
+ from ipa_forge.bundle.models import AppBundle, MachOKind
10
+
11
+ _MACHO_MAGICS = {
12
+ 0xFEEDFACE, # MH_MAGIC (32-bit)
13
+ 0xFEEDFACF, # MH_MAGIC_64
14
+ 0xCEFAEDFE, # MH_CIGAM (32-bit, byte-swapped)
15
+ 0xCFFAEDFE, # MH_CIGAM_64
16
+ 0xCAFEBABE, # FAT_MAGIC
17
+ 0xBEBAFECA, # FAT_CIGAM
18
+ }
19
+
20
+
21
+ def is_macho_file(path: Path) -> bool:
22
+ if not path.is_file() or path.is_symlink():
23
+ return False
24
+ try:
25
+ with open(path, "rb") as f:
26
+ header = f.read(4)
27
+ except OSError:
28
+ return False
29
+ if len(header) < 4:
30
+ return False
31
+ (magic,) = struct.unpack(">I", header)
32
+ return magic in _MACHO_MAGICS
33
+
34
+
35
+ def bundle_executable_paths(bundle: AppBundle, kinds: set[MachOKind] | None = None) -> list[Path]:
36
+ """Every on-disk executable in the bundle, main binary first: the main
37
+ executable plus each `MachOTarget` whose `kind` is in `kinds` (default:
38
+ every kind -- main, framework, dylib, appex, watch_app, other). Callers
39
+ doing a narrower analysis (e.g. hook verification, which only cares
40
+ about app code) pass an explicit, smaller `kinds` set."""
41
+ paths = [bundle.root / bundle.main_executable_name]
42
+ for target in bundle.executables:
43
+ # the main executable is always target.kind == "main" and already
44
+ # covered by the first line above -- skip it here or it'd be listed
45
+ # (and analyzed) twice.
46
+ if target.kind == "main":
47
+ continue
48
+ if kinds is None or target.kind in kinds:
49
+ paths.append(bundle.extraction_root / Path(target.bundle_relative))
50
+ return [p for p in paths if p.is_file()]
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """LIEF-backed dylib load-command injection.
3
+
4
+ Modifies the target Mach-O's load commands only -- copying the dylib file
5
+ into the bundle (if it isn't already present) is a separate `resource_add`
6
+ patch operation, and re-signing (required because LIEF's rewrite invalidates
7
+ any existing code signature) happens later in the pipeline's signing stage.
8
+ Never silently continues past a load-command failure: every outcome maps to
9
+ one of the four explicit statuses below.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import enum
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ import lief
19
+
20
+ from ipa_forge.machO.arch import (
21
+ AmbiguousArchError,
22
+ ArchNotFoundError,
23
+ NotMachOError,
24
+ arch_name,
25
+ require_slice,
26
+ )
27
+
28
+
29
+ class InjectionStatus(enum.Enum):
30
+ INJECTED = "INJECTED"
31
+ INJECTION_SKIPPED = "INJECTION_SKIPPED"
32
+ INJECTION_UNSUPPORTED = "INJECTION_UNSUPPORTED"
33
+ INJECTION_FAILED = "INJECTION_FAILED"
34
+
35
+
36
+ @dataclass
37
+ class InjectionResult:
38
+ status: InjectionStatus
39
+ message: str = ""
40
+
41
+
42
+ _FACTORY_BY_LOAD_COMMAND = {
43
+ "LC_LOAD_DYLIB": lief.MachO.DylibCommand.load_dylib,
44
+ "LC_LOAD_WEAK_DYLIB": lief.MachO.DylibCommand.weak_lib,
45
+ }
46
+
47
+
48
+ def _select_slice(fat: lief.MachO.FatBinary, arch: str | None) -> lief.MachO.Binary:
49
+ """Like machO.arch.load_macho, but keeps the FatBinary container so the
50
+ caller can write it back whole -- writing an individually-selected slice
51
+ directly (`binary.write(path)`) discards every *other* slice in a
52
+ universal binary, silently corrupting it."""
53
+ if len(fat) == 1:
54
+ return require_slice(fat, 0)
55
+
56
+ available = [arch_name(require_slice(fat, i)) for i in range(len(fat))]
57
+ if arch is None:
58
+ raise AmbiguousArchError(
59
+ f"universal binary with architectures {available}; an explicit `arch:` selector is required"
60
+ )
61
+ for i in range(len(fat)):
62
+ binary = require_slice(fat, i)
63
+ if arch_name(binary) == arch:
64
+ return binary
65
+ raise ArchNotFoundError(f"architecture '{arch}' not found; available: {available}")
66
+
67
+
68
+ def inject_dylib(
69
+ target_path: Path,
70
+ dylib_install_name: str,
71
+ arch: str | None = None,
72
+ load_command: str = "LC_LOAD_DYLIB",
73
+ ) -> InjectionResult:
74
+ if load_command not in _FACTORY_BY_LOAD_COMMAND:
75
+ return InjectionResult(InjectionStatus.INJECTION_FAILED, f"unknown load_command '{load_command}'")
76
+
77
+ fat = lief.MachO.parse(str(target_path))
78
+ if fat is None or len(fat) == 0:
79
+ return InjectionResult(InjectionStatus.INJECTION_UNSUPPORTED, f"{target_path} is not a valid Mach-O file")
80
+
81
+ try:
82
+ binary = _select_slice(fat, arch)
83
+ except NotMachOError as e:
84
+ return InjectionResult(InjectionStatus.INJECTION_UNSUPPORTED, str(e))
85
+ except (AmbiguousArchError, ArchNotFoundError) as e:
86
+ return InjectionResult(InjectionStatus.INJECTION_FAILED, str(e))
87
+
88
+ if dylib_install_name in {lib.name for lib in binary.libraries}:
89
+ return InjectionResult(InjectionStatus.INJECTION_SKIPPED, f"{dylib_install_name} is already loaded")
90
+
91
+ try:
92
+ command = _FACTORY_BY_LOAD_COMMAND[load_command](dylib_install_name)
93
+ if binary.add(command) is None:
94
+ return InjectionResult(InjectionStatus.INJECTION_FAILED, "LIEF failed to add the load command")
95
+ fat.write(str(target_path))
96
+ except Exception as e: # noqa: BLE001 -- LIEF raises broad/internal exception types on malformed input
97
+ return InjectionResult(InjectionStatus.INJECTION_FAILED, f"LIEF injection failed: {e}")
98
+
99
+ return InjectionResult(InjectionStatus.INJECTED, f"added {load_command} {dylib_install_name}")