BoringSecretHunter 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ """BoringSecretHunter - Extract ssl_log_secret() offsets from BoringSSL/RustLS binaries."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("BoringSecretHunter")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0-dev"
9
+
10
+ from boring_secret_hunter.analyzer import (
11
+ analyze,
12
+ analyze_binary,
13
+ analyze_parallel,
14
+ discover_binaries,
15
+ )
16
+ from boring_secret_hunter.binary_classifier import BinaryType, classify_binary
17
+ from boring_secret_hunter.config import find_ghidra, find_java
18
+ from boring_secret_hunter.ghidra_runner import GhidraError
19
+ from boring_secret_hunter.result import AnalysisResult, BatchResult, FunctionMatch
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ # Analysis
24
+ "analyze",
25
+ "analyze_binary",
26
+ "analyze_parallel",
27
+ "discover_binaries",
28
+ # Data models
29
+ "AnalysisResult",
30
+ "BatchResult",
31
+ "FunctionMatch",
32
+ # Config helpers
33
+ "find_ghidra",
34
+ "find_java",
35
+ # Classification
36
+ "BinaryType",
37
+ "classify_binary",
38
+ # Errors
39
+ "GhidraError",
40
+ ]
@@ -0,0 +1,6 @@
1
+ """Support running as `python -m boring_secret_hunter`."""
2
+
3
+ from boring_secret_hunter.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,287 @@
1
+ """Main analysis orchestrator — ports ghidra_analysis.sh."""
2
+
3
+ import logging
4
+ from concurrent.futures import ThreadPoolExecutor
5
+
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Callable, Dict, List, Optional
9
+
10
+ from boring_secret_hunter.archive_extractor import extract_binaries, is_archive
11
+ from boring_secret_hunter.arch_detector import detect_architecture, detect_from_siblings
12
+ from boring_secret_hunter.binary_classifier import (
13
+ BinaryType,
14
+ classify_binary,
15
+ is_archive_extension,
16
+ )
17
+ from boring_secret_hunter.ghidra_runner import GhidraError, parse_output, run_analysis
18
+ from boring_secret_hunter.result import AnalysisResult, BatchResult, FunctionMatch
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # Threshold for "large dump" mode (in bytes)
23
+ LARGE_DUMP_THRESHOLD = 100 * 1024 * 1024 # 100 MB
24
+
25
+
26
+ def discover_binaries(path: Path) -> List[Path]:
27
+ """Discover all supported binaries at the given path.
28
+
29
+ If path is a file, returns [path] if it's a supported binary or archive.
30
+ If path is a directory, returns all supported binaries in the directory.
31
+ """
32
+ if path.is_file():
33
+ return [path]
34
+
35
+ if not path.is_dir():
36
+ log.error("Path does not exist: %s", path)
37
+ return []
38
+
39
+ binaries = []
40
+ for item in sorted(path.iterdir()):
41
+ if not item.is_file():
42
+ continue
43
+ if is_archive_extension(item):
44
+ continue
45
+ bin_type = classify_binary(item)
46
+ if bin_type != BinaryType.UNKNOWN:
47
+ binaries.append(item)
48
+ return binaries
49
+
50
+
51
+ def _parse_function_matches(parsed_output: str) -> List[FunctionMatch]:
52
+ """Parse function match info from the BoringSecretHunter output."""
53
+ functions = []
54
+ current = FunctionMatch()
55
+
56
+ for line in parsed_output.splitlines():
57
+ line = line.strip()
58
+ if not line:
59
+ continue
60
+
61
+ # Look for function label patterns
62
+ if "Function Label:" in line or "function label:" in line.lower():
63
+ if current.label:
64
+ functions.append(current)
65
+ current = FunctionMatch()
66
+ current.label = line.split(":", 1)[1].strip()
67
+ elif "Ghidra Offset:" in line or "ghidra offset:" in line.lower():
68
+ current.ghidra_offset = line.split(":", 1)[1].strip()
69
+ elif "IDA Offset:" in line or "ida offset:" in line.lower():
70
+ current.ida_offset = line.split(":", 1)[1].strip()
71
+ elif "Byte Pattern:" in line or "byte pattern:" in line.lower():
72
+ current.byte_pattern = line.split(":", 1)[1].strip()
73
+
74
+ if current.label:
75
+ functions.append(current)
76
+
77
+ return functions
78
+
79
+
80
+ def _detect_tls_library(parsed_output: str) -> str:
81
+ """Detect TLS library type from output."""
82
+ lower = parsed_output.lower()
83
+ if "boringssl" in lower:
84
+ return "BoringSSL"
85
+ elif "rustls" in lower:
86
+ return "RustLS"
87
+ elif "openssl" in lower:
88
+ return "OpenSSL"
89
+ return "unknown"
90
+
91
+
92
+ def analyze_binary(
93
+ binary_path: Path,
94
+ ghidra_dir: str,
95
+ debug: bool = False,
96
+ large_dump_mode: Optional[str] = None,
97
+ processor_override: Optional[str] = None,
98
+ ) -> AnalysisResult:
99
+ """Analyze a single binary.
100
+
101
+ Args:
102
+ binary_path: Path to the binary
103
+ ghidra_dir: Path to Ghidra installation
104
+ debug: Enable debug output
105
+ large_dump_mode: Mode for large dumps ('normal', 'fast', 'skip')
106
+ processor_override: Override processor detection with this value
107
+ """
108
+ result = AnalysisResult(
109
+ binary_name=binary_path.name,
110
+ binary_path=str(binary_path),
111
+ )
112
+
113
+ bin_type = classify_binary(binary_path)
114
+ result.binary_type = bin_type.value
115
+
116
+ # Detect architecture
117
+ arch = detect_architecture(binary_path)
118
+ if arch:
119
+ result.architecture = arch
120
+
121
+ # Build extra args for raw data files
122
+ extra_args = []
123
+ if bin_type == BinaryType.RAW_DATA:
124
+ proc = processor_override or detect_from_siblings(
125
+ binary_path.parent, processor_override
126
+ )
127
+ result.architecture = proc
128
+ extra_args = ["-processor", proc, "-loader", "BinaryLoader"]
129
+ log.info(" Raw data file detected, using processor: %s", proc)
130
+
131
+ # Check size for large dump handling
132
+ file_size = binary_path.stat().st_size
133
+ if file_size > LARGE_DUMP_THRESHOLD:
134
+ size_mb = file_size // (1024 * 1024)
135
+ mode = large_dump_mode or "normal"
136
+ if mode == "skip":
137
+ log.info(
138
+ " Skipping %s (%d MB) per user choice.",
139
+ binary_path.name,
140
+ size_mb,
141
+ )
142
+ result.error = f"Skipped (large dump: {size_mb} MB)"
143
+ return result
144
+
145
+ try:
146
+ raw_output = run_analysis(
147
+ binary_path=binary_path,
148
+ ghidra_dir=ghidra_dir,
149
+ extra_args=extra_args if extra_args else None,
150
+ debug=debug,
151
+ large_dump_mode=large_dump_mode,
152
+ )
153
+ result.raw_output = raw_output
154
+
155
+ parsed = parse_output(raw_output)
156
+ if parsed.strip():
157
+ result.success = True
158
+ result.functions = _parse_function_matches(parsed)
159
+ result.tls_library_type = _detect_tls_library(parsed)
160
+ else:
161
+ log.warning(
162
+ " No results found in Ghidra output for %s", binary_path.name
163
+ )
164
+ if debug:
165
+ log.debug("Raw output:\n%s", raw_output)
166
+
167
+ if debug:
168
+ log.debug("Raw Ghidra output:\n%s", raw_output)
169
+
170
+ except GhidraError as e:
171
+ result.error = str(e)
172
+ log.error(" Analysis failed: %s", e)
173
+
174
+ return result
175
+
176
+
177
+ def analyze(
178
+ path: Path,
179
+ ghidra_dir: str,
180
+ debug: bool = False,
181
+ large_dump_mode: Optional[str] = None,
182
+ processor_override: Optional[str] = None,
183
+ on_result: Optional[Callable[[AnalysisResult, str], None]] = None,
184
+ ) -> BatchResult:
185
+ """Analyze one or more binaries.
186
+
187
+ Handles archives (APK/IPA/ZIP), single files, and directories.
188
+
189
+ Args:
190
+ path: Path to binary, archive, or directory
191
+ ghidra_dir: Path to Ghidra installation
192
+ debug: Enable debug output
193
+ large_dump_mode: Mode for large dumps
194
+ processor_override: Override processor for raw dumps
195
+ """
196
+ batch = BatchResult()
197
+ temp_dirs = []
198
+
199
+ try:
200
+ # Handle archives
201
+ if path.is_file() and is_archive(path):
202
+ extracted, tmp_dir = extract_binaries(path)
203
+ temp_dirs.append(tmp_dir)
204
+ binaries = extracted
205
+ else:
206
+ binaries = discover_binaries(path)
207
+
208
+ batch.total = len(binaries)
209
+
210
+ if not binaries:
211
+ log.warning(
212
+ "No supported binaries found. Supported types: ELF, Mach-O, PE32, raw data files."
213
+ )
214
+ return batch
215
+
216
+ if len(binaries) == 1:
217
+ log.info("Found 1 binary to analyze:")
218
+ else:
219
+ log.info("Found %d binaries to analyze:", len(binaries))
220
+ for i, b in enumerate(binaries, 1):
221
+ log.info(" %d. %s", i, b.name)
222
+
223
+ for i, binary in enumerate(binaries, 1):
224
+ log.info("[%d/%d] Analyzing %s...", i, batch.total, binary.name)
225
+
226
+ result = analyze_binary(
227
+ binary_path=binary,
228
+ ghidra_dir=ghidra_dir,
229
+ debug=debug,
230
+ large_dump_mode=large_dump_mode,
231
+ processor_override=processor_override,
232
+ )
233
+ batch.results.append(result)
234
+
235
+ if result.success:
236
+ batch.successful += 1
237
+ parsed = parse_output(result.raw_output)
238
+ if parsed.strip():
239
+ log.debug("Parsed output for %s:\n%s", binary.name, parsed)
240
+ if on_result is not None:
241
+ on_result(result, parsed)
242
+
243
+ finally:
244
+ for tmp_dir in temp_dirs:
245
+ shutil.rmtree(tmp_dir, ignore_errors=True)
246
+
247
+ return batch
248
+
249
+
250
+ def analyze_parallel(
251
+ binaries: List[Path],
252
+ ghidra_dir: str,
253
+ max_workers: int = 4,
254
+ debug: bool = False,
255
+ large_dump_mode: Optional[str] = None,
256
+ processor_override: Optional[str] = None,
257
+ ) -> Dict[Path, AnalysisResult]:
258
+ """Analyze multiple binaries in parallel using threads.
259
+
260
+ Args:
261
+ binaries: List of binary paths to analyze
262
+ ghidra_dir: Path to Ghidra installation
263
+ max_workers: Maximum number of concurrent analyses
264
+ debug: Enable debug output
265
+ large_dump_mode: Mode for large dumps ('normal', 'fast', 'skip')
266
+ processor_override: Override processor for raw dumps
267
+
268
+ Returns:
269
+ Dictionary mapping each binary path to its AnalysisResult.
270
+ """
271
+ results: Dict[Path, AnalysisResult] = {}
272
+
273
+ def _run(binary_path: Path) -> tuple:
274
+ result = analyze_binary(
275
+ binary_path=binary_path,
276
+ ghidra_dir=ghidra_dir,
277
+ debug=debug,
278
+ large_dump_mode=large_dump_mode,
279
+ processor_override=processor_override,
280
+ )
281
+ return binary_path, result
282
+
283
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
284
+ for path, result in executor.map(_run, binaries):
285
+ results[path] = result
286
+
287
+ return results
@@ -0,0 +1,118 @@
1
+ """Detect CPU architecture from binary headers."""
2
+
3
+ import struct
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from boring_secret_hunter.binary_classifier import (
8
+ BinaryType,
9
+ classify_binary,
10
+ _MACHO_MAGIC_64_LE,
11
+ _MACHO_MAGIC_32_LE,
12
+ _MACHO_MAGIC_64_BE,
13
+ _MACHO_MAGIC_32_BE,
14
+ _MACHO_FAT_BE,
15
+ _MACHO_FAT_LE,
16
+ )
17
+
18
+
19
+ # ELF e_machine values (at offset 18, 2 bytes)
20
+ _ELF_MACHINES = {
21
+ 0x03: "x86:LE:32:default", # EM_386
22
+ 0x3E: "x86:LE:64:default", # EM_X86_64
23
+ 0x28: "ARM:LE:32:v8", # EM_ARM
24
+ 0xB7: "AARCH64:LE:64:v8A", # EM_AARCH64
25
+ 0x08: "MIPS:BE:32:default", # EM_MIPS
26
+ 0xF3: "RISCV:LE:64:default", # EM_RISCV
27
+ }
28
+
29
+ # Mach-O cputype values (at offset 4, 4 bytes)
30
+ _MACHO_CPUTYPES_LE = {
31
+ 0x0C: "ARM:LE:32:v8", # CPU_TYPE_ARM
32
+ 0x0100000C: "AARCH64:LE:64:v8A", # CPU_TYPE_ARM64
33
+ 0x07: "x86:LE:32:default", # CPU_TYPE_X86
34
+ 0x01000007: "x86:LE:64:default", # CPU_TYPE_X86_64
35
+ }
36
+
37
+ DEFAULT_PROCESSOR = "AARCH64:LE:64:v8A"
38
+
39
+
40
+ def detect_architecture(path: Path) -> Optional[str]:
41
+ """Detect Ghidra processor string from binary headers.
42
+
43
+ Returns a Ghidra-compatible processor string like 'AARCH64:LE:64:v8A'
44
+ or None if detection fails.
45
+ """
46
+ bin_type = classify_binary(path)
47
+
48
+ if bin_type == BinaryType.ELF:
49
+ return _detect_elf_arch(path)
50
+ elif bin_type == BinaryType.MACHO:
51
+ return _detect_macho_arch(path)
52
+ return None
53
+
54
+
55
+ def _detect_elf_arch(path: Path) -> Optional[str]:
56
+ """Read ELF e_machine field at offset 18."""
57
+ try:
58
+ with open(path, "rb") as f:
59
+ f.seek(5)
60
+ ei_data = struct.unpack("B", f.read(1))[0]
61
+ endian = "<" if ei_data == 1 else ">"
62
+ f.seek(18)
63
+ e_machine = struct.unpack(f"{endian}H", f.read(2))[0]
64
+ return _ELF_MACHINES.get(e_machine)
65
+ except (OSError, struct.error):
66
+ return None
67
+
68
+
69
+ def _detect_macho_arch(path: Path) -> Optional[str]:
70
+ """Read Mach-O cputype field at offset 4."""
71
+ try:
72
+ with open(path, "rb") as f:
73
+ magic = f.read(4)
74
+ # Determine endianness from magic
75
+ if magic in (_MACHO_MAGIC_64_LE, _MACHO_MAGIC_32_LE):
76
+ endian = "<"
77
+ elif magic in (_MACHO_MAGIC_64_BE, _MACHO_MAGIC_32_BE):
78
+ endian = ">"
79
+ elif magic == _MACHO_FAT_BE:
80
+ # Fat binary - read first arch
81
+ endian = ">"
82
+ f.seek(8) # skip to first arch cputype
83
+ elif magic == _MACHO_FAT_LE:
84
+ endian = "<"
85
+ f.seek(8)
86
+ else:
87
+ return None
88
+ cputype = struct.unpack(f"{endian}I", f.read(4))[0]
89
+ return _MACHO_CPUTYPES_LE.get(cputype)
90
+ except (OSError, struct.error):
91
+ return None
92
+
93
+
94
+ def detect_from_siblings(
95
+ directory: Path, processor_override: Optional[str] = None
96
+ ) -> str:
97
+ """Detect processor from sibling binaries in the same directory.
98
+
99
+ Used for raw data dumps where the binary itself has no headers.
100
+
101
+ Args:
102
+ directory: Directory to scan for sibling binaries
103
+ processor_override: If set, use this value directly (from CLI --processor flag)
104
+
105
+ Returns:
106
+ Ghidra processor string, defaults to AARCH64:LE:64:v8A
107
+ """
108
+ if processor_override:
109
+ return processor_override
110
+
111
+ for sibling in directory.iterdir():
112
+ if not sibling.is_file():
113
+ continue
114
+ arch = detect_architecture(sibling)
115
+ if arch:
116
+ return arch
117
+
118
+ return DEFAULT_PROCESSOR
@@ -0,0 +1,74 @@
1
+ """Extract binaries from APK/IPA/ZIP archives."""
2
+
3
+ import logging
4
+ import shutil
5
+ import tempfile
6
+ import zipfile
7
+ from pathlib import Path
8
+ from typing import List, Tuple
9
+
10
+ from boring_secret_hunter.binary_classifier import BinaryType, classify_binary
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ # Archive types recognized by extension
15
+ _ARCHIVE_EXTENSIONS = {".apk", ".ipa", ".zip"}
16
+
17
+
18
+ def is_archive(path: Path) -> bool:
19
+ """Check if a file is a supported archive type."""
20
+ return path.suffix.lower() in _ARCHIVE_EXTENSIONS
21
+
22
+
23
+ def extract_binaries(archive_path: Path) -> Tuple[List[Path], Path]:
24
+ """Extract binaries from an archive.
25
+
26
+ For APK: extracts only .so files that are ELF binaries.
27
+ For IPA: extracts all Mach-O binaries.
28
+ For ZIP: extracts all ELF and Mach-O binaries.
29
+
30
+ Returns:
31
+ Tuple of (list of extracted binary paths, temp directory to clean up)
32
+ """
33
+ suffix = archive_path.suffix.lower()
34
+ tmp_dir = Path(tempfile.mkdtemp(prefix="bsh_extract_"))
35
+
36
+ log.info("Extracting archive: %s", archive_path.name)
37
+
38
+ try:
39
+ with zipfile.ZipFile(archive_path, "r") as zf:
40
+ zf.extractall(tmp_dir)
41
+ except (zipfile.BadZipFile, Exception) as e:
42
+ log.warning("Failed to extract %s: %s", archive_path.name, e)
43
+ shutil.rmtree(tmp_dir, ignore_errors=True)
44
+ return [], tmp_dir
45
+
46
+ extracted = []
47
+ for candidate in tmp_dir.rglob("*"):
48
+ if not candidate.is_file() or candidate.stat().st_size < 1024:
49
+ continue
50
+
51
+ bin_type = classify_binary(candidate)
52
+ should_extract = False
53
+
54
+ if suffix == ".apk":
55
+ if candidate.suffix == ".so" and bin_type == BinaryType.ELF:
56
+ should_extract = True
57
+ elif suffix == ".ipa":
58
+ if bin_type == BinaryType.MACHO:
59
+ should_extract = True
60
+ else:
61
+ if bin_type in (BinaryType.ELF, BinaryType.MACHO):
62
+ should_extract = True
63
+
64
+ if should_extract:
65
+ relative = candidate.relative_to(tmp_dir)
66
+ sanitized = str(relative).replace("/", "__").replace("\\", "__")
67
+ dest_name = f"{archive_path.name}__{sanitized}"
68
+ dest_path = tmp_dir / dest_name
69
+ if candidate != dest_path:
70
+ shutil.copy2(candidate, dest_path)
71
+ extracted.append(dest_path)
72
+ log.info(" Found: %s", relative)
73
+
74
+ return extracted, tmp_dir
@@ -0,0 +1,66 @@
1
+ """Classify binary files by magic bytes."""
2
+
3
+ import enum
4
+ from pathlib import Path
5
+
6
+
7
+ class BinaryType(enum.Enum):
8
+ ELF = "elf"
9
+ MACHO = "macho"
10
+ PE32 = "pe32"
11
+ RAW_DATA = "data"
12
+ UNKNOWN = "unknown"
13
+
14
+
15
+ # Magic byte signatures
16
+ _ELF_MAGIC = b"\x7fELF"
17
+ _MACHO_MAGIC_64_LE = b"\xcf\xfa\xed\xfe" # 64-bit little-endian
18
+ _MACHO_MAGIC_32_LE = b"\xce\xfa\xed\xfe" # 32-bit little-endian
19
+ _MACHO_MAGIC_64_BE = b"\xfe\xed\xfa\xcf" # 64-bit big-endian
20
+ _MACHO_MAGIC_32_BE = b"\xfe\xed\xfa\xce" # 32-bit big-endian
21
+ _MACHO_FAT_BE = b"\xca\xfe\xba\xbe" # Universal/fat binary
22
+ _MACHO_FAT_LE = b"\xbe\xba\xfe\xca" # Universal/fat binary (LE)
23
+ _PE_MAGIC = b"MZ"
24
+
25
+
26
+ def classify_binary(path: Path) -> BinaryType:
27
+ """Classify a binary file by reading its magic bytes.
28
+
29
+ Returns BinaryType enum indicating the file type.
30
+ """
31
+ try:
32
+ with open(path, "rb") as f:
33
+ header = f.read(4)
34
+ except (OSError, IOError):
35
+ return BinaryType.UNKNOWN
36
+
37
+ if len(header) < 2:
38
+ return BinaryType.UNKNOWN
39
+
40
+ if header[:4] == _ELF_MAGIC:
41
+ return BinaryType.ELF
42
+
43
+ if header[:4] in (
44
+ _MACHO_MAGIC_64_LE,
45
+ _MACHO_MAGIC_32_LE,
46
+ _MACHO_MAGIC_64_BE,
47
+ _MACHO_MAGIC_32_BE,
48
+ _MACHO_FAT_BE,
49
+ _MACHO_FAT_LE,
50
+ ):
51
+ return BinaryType.MACHO
52
+
53
+ if header[:2] == _PE_MAGIC:
54
+ return BinaryType.PE32
55
+
56
+ return BinaryType.RAW_DATA
57
+
58
+
59
+ def is_supported_binary(path: Path) -> bool:
60
+ """Check if a file is a supported binary type (not UNKNOWN)."""
61
+ return classify_binary(path) != BinaryType.UNKNOWN
62
+
63
+
64
+ def is_archive_extension(path: Path) -> bool:
65
+ """Check if a file has an archive extension (to skip during classification)."""
66
+ return path.suffix.lower() in {".apk", ".ipa", ".zip", ".jar"}