pybinaryguard 1.0.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 (91) hide show
  1. pybinaryguard/__init__.py +78 -0
  2. pybinaryguard/__main__.py +7 -0
  3. pybinaryguard/_compat/__init__.py +0 -0
  4. pybinaryguard/agent/__init__.py +63 -0
  5. pybinaryguard/agent/guard.py +232 -0
  6. pybinaryguard/agent/recommender.py +283 -0
  7. pybinaryguard/agent/schema.py +200 -0
  8. pybinaryguard/agent/simulator.py +430 -0
  9. pybinaryguard/agent/tool_interface.py +474 -0
  10. pybinaryguard/analyzers/__init__.py +209 -0
  11. pybinaryguard/analyzers/base.py +40 -0
  12. pybinaryguard/analyzers/dependency_analyzer.py +336 -0
  13. pybinaryguard/analyzers/elf_analyzer.py +754 -0
  14. pybinaryguard/analyzers/symbol_analyzer.py +280 -0
  15. pybinaryguard/analyzers/wheel_analyzer.py +308 -0
  16. pybinaryguard/cli/__init__.py +5 -0
  17. pybinaryguard/cli/commands.py +414 -0
  18. pybinaryguard/cli/formatters.py +720 -0
  19. pybinaryguard/cli/main.py +250 -0
  20. pybinaryguard/diagnostics/__init__.py +13 -0
  21. pybinaryguard/diagnostics/explainer.py +356 -0
  22. pybinaryguard/diagnostics/findings.py +146 -0
  23. pybinaryguard/diagnostics/suggestions.py +508 -0
  24. pybinaryguard/frameworks/__init__.py +20 -0
  25. pybinaryguard/frameworks/onnxruntime.py +214 -0
  26. pybinaryguard/frameworks/pytorch.py +223 -0
  27. pybinaryguard/frameworks/tensorflow.py +266 -0
  28. pybinaryguard/frameworks/tensorrt.py +189 -0
  29. pybinaryguard/models/__init__.py +19 -0
  30. pybinaryguard/models/enums.py +102 -0
  31. pybinaryguard/models/finding.py +109 -0
  32. pybinaryguard/models/package.py +121 -0
  33. pybinaryguard/models/system.py +118 -0
  34. pybinaryguard/plugins/__init__.py +19 -0
  35. pybinaryguard/plugins/contrib/__init__.py +7 -0
  36. pybinaryguard/plugins/contrib/gstreamer.py +109 -0
  37. pybinaryguard/plugins/contrib/jetson.py +385 -0
  38. pybinaryguard/plugins/contrib/opencv.py +306 -0
  39. pybinaryguard/plugins/contrib/tensorrt.py +426 -0
  40. pybinaryguard/plugins/hooks.py +273 -0
  41. pybinaryguard/plugins/loader.py +190 -0
  42. pybinaryguard/predictor/__init__.py +23 -0
  43. pybinaryguard/predictor/dependency_graph.py +226 -0
  44. pybinaryguard/predictor/linker_simulator.py +161 -0
  45. pybinaryguard/predictor/predictor.py +144 -0
  46. pybinaryguard/predictor/resolver.py +282 -0
  47. pybinaryguard/probes/__init__.py +73 -0
  48. pybinaryguard/probes/base.py +45 -0
  49. pybinaryguard/probes/board_probe.py +248 -0
  50. pybinaryguard/probes/cpu_probe.py +176 -0
  51. pybinaryguard/probes/glibc_probe.py +215 -0
  52. pybinaryguard/probes/gpu_probe.py +430 -0
  53. pybinaryguard/probes/library_probe.py +132 -0
  54. pybinaryguard/probes/os_probe.py +227 -0
  55. pybinaryguard/probes/python_probe.py +135 -0
  56. pybinaryguard/probes/toolchain_probe.py +89 -0
  57. pybinaryguard/probes/venv_probe.py +111 -0
  58. pybinaryguard/profiles/__init__.py +12 -0
  59. pybinaryguard/profiles/engine.py +343 -0
  60. pybinaryguard/rules/__init__.py +24 -0
  61. pybinaryguard/rules/base.py +57 -0
  62. pybinaryguard/rules/builtin/__init__.py +136 -0
  63. pybinaryguard/rules/builtin/arch_rules.py +86 -0
  64. pybinaryguard/rules/builtin/board_profile_rules.py +282 -0
  65. pybinaryguard/rules/builtin/container_rules.py +170 -0
  66. pybinaryguard/rules/builtin/cpu_rules.py +204 -0
  67. pybinaryguard/rules/builtin/cuda_rules.py +760 -0
  68. pybinaryguard/rules/builtin/dependency_rules.py +278 -0
  69. pybinaryguard/rules/builtin/framework_rules.py +252 -0
  70. pybinaryguard/rules/builtin/glibc_rules.py +320 -0
  71. pybinaryguard/rules/builtin/numpy_rules.py +110 -0
  72. pybinaryguard/rules/builtin/predictive_rules.py +165 -0
  73. pybinaryguard/rules/builtin/python_abi_rules.py +259 -0
  74. pybinaryguard/rules/builtin/source_build_rules.py +150 -0
  75. pybinaryguard/rules/builtin/venv_rules.py +159 -0
  76. pybinaryguard/rules/engine.py +123 -0
  77. pybinaryguard/scanner.py +904 -0
  78. pybinaryguard/scoring/__init__.py +19 -0
  79. pybinaryguard/scoring/engine.py +416 -0
  80. pybinaryguard/snapshot/__init__.py +20 -0
  81. pybinaryguard/snapshot/generator.py +168 -0
  82. pybinaryguard/snapshot/lockfile.py +152 -0
  83. pybinaryguard/snapshot/verifier.py +315 -0
  84. pybinaryguard/validators/__init__.py +7 -0
  85. pybinaryguard/validators/import_validator.py +231 -0
  86. pybinaryguard-1.0.0.dist-info/METADATA +876 -0
  87. pybinaryguard-1.0.0.dist-info/RECORD +91 -0
  88. pybinaryguard-1.0.0.dist-info/WHEEL +5 -0
  89. pybinaryguard-1.0.0.dist-info/entry_points.txt +8 -0
  90. pybinaryguard-1.0.0.dist-info/licenses/LICENSE +21 -0
  91. pybinaryguard-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,754 @@
1
+ """ELF binary analyzer with a minimal pure-Python ELF parser.
2
+
3
+ This module provides ``MinimalELFParser`` -- a zero-dependency ELF reader
4
+ that extracts only the metadata PyBinaryGuard needs -- and ``ELFAnalyzer``,
5
+ which walks a package's installed files and populates
6
+ ``PackageBinaryInfo.shared_objects``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import struct
14
+ from typing import BinaryIO, Dict, List, Optional, Tuple
15
+
16
+ from pybinaryguard.analyzers.base import AnalyzerBase
17
+ from pybinaryguard.models.enums import Architecture
18
+ from pybinaryguard.models.package import PackageBinaryInfo, SharedObjectInfo
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # ELF constants
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _ELF_MAGIC = b"\x7fELF"
27
+
28
+ # EI_CLASS
29
+ _ELFCLASS32 = 1
30
+ _ELFCLASS64 = 2
31
+
32
+ # EI_DATA
33
+ _ELFDATA2LSB = 1 # little-endian
34
+ _ELFDATA2MSB = 2 # big-endian
35
+
36
+ # Section header types
37
+ _SHT_NOTE = 7
38
+ _SHT_DYNAMIC = 6
39
+ _SHT_DYNSYM = 11
40
+ _SHT_STRTAB = 3
41
+ _SHT_GNU_VERNEED = 0x6FFFFFFE
42
+
43
+ # Dynamic tag types
44
+ _DT_NULL = 0
45
+ _DT_NEEDED = 1
46
+ _DT_STRTAB = 5
47
+ _DT_STRSZ = 10
48
+ _DT_SONAME = 14
49
+ _DT_RPATH = 15
50
+ _DT_RUNPATH = 29
51
+
52
+ # Note types
53
+ _NT_GNU_BUILD_ID = 3
54
+
55
+ # e_machine -> Architecture mapping
56
+ _EM_TO_ARCH: Dict[int, Architecture] = {
57
+ 3: Architecture.I686, # EM_386
58
+ 21: Architecture.PPC64LE, # EM_PPC64
59
+ 22: Architecture.S390X, # EM_S390
60
+ 40: Architecture.ARMV7L, # EM_ARM
61
+ 62: Architecture.X86_64, # EM_X86_64
62
+ 183: Architecture.AARCH64, # EM_AARCH64
63
+ }
64
+
65
+ # Program header types
66
+ _PT_DYNAMIC = 2
67
+
68
+
69
+ class ELFParseError(Exception):
70
+ """Raised when a file is not a valid ELF binary or is truncated."""
71
+
72
+
73
+ class MinimalELFParser:
74
+ """Minimal pure-Python ELF parser.
75
+
76
+ Extracts only the subset of ELF metadata needed by PyBinaryGuard:
77
+
78
+ * ELF class (32/64), endianness, ``e_machine``
79
+ * ``DT_NEEDED``, ``DT_SONAME``, ``DT_RPATH``, ``DT_RUNPATH``
80
+ * GNU version requirements (``SHT_GNU_VERNEED``)
81
+ * GNU build-id (``NT_GNU_BUILD_ID``)
82
+
83
+ The parser reads *only* the sections it needs -- it never loads the
84
+ entire file into memory.
85
+
86
+ Parameters
87
+ ----------
88
+ path:
89
+ Absolute path to the ELF file.
90
+ """
91
+
92
+ def __init__(self, path: str) -> None:
93
+ self._path = path
94
+ self._ei_class: int = 0 # 1=32, 2=64
95
+ self._ei_data: int = 0 # 1=LE, 2=BE
96
+ self._e_machine: int = 0
97
+ self._e_type: int = 0
98
+
99
+ # Section headers (offset, size, type, name_index, sh_link, sh_info, sh_entsize)
100
+ self._sections: List[Dict[str, int]] = []
101
+ self._shstrtab_offset: int = 0
102
+ self._shstrtab_size: int = 0
103
+
104
+ # Program headers
105
+ self._phdrs: List[Dict[str, int]] = []
106
+
107
+ # Extracted dynamic entries
108
+ self._dt_needed_offsets: List[int] = []
109
+ self._dt_soname_offset: Optional[int] = None
110
+ self._dt_rpath_offset: Optional[int] = None
111
+ self._dt_runpath_offset: Optional[int] = None
112
+ self._dynstr_offset: int = 0
113
+ self._dynstr_size: int = 0
114
+
115
+ # Resolved strings
116
+ self._needed: List[str] = []
117
+ self._soname: Optional[str] = None
118
+ self._rpath: Optional[str] = None
119
+ self._runpath: Optional[str] = None
120
+
121
+ # Version requirements: list of (library, version_string)
122
+ self._version_requirements: List[Tuple[str, str]] = []
123
+
124
+ # Build-id hex string
125
+ self._build_id: Optional[str] = None
126
+
127
+ self._parsed = False
128
+
129
+ # ------------------------------------------------------------------
130
+ # Struct helpers
131
+ # ------------------------------------------------------------------
132
+
133
+ def _endian_prefix(self) -> str:
134
+ """Return struct byte-order prefix for the ELF endianness."""
135
+ return "<" if self._ei_data == _ELFDATA2LSB else ">"
136
+
137
+ def _ptr_fmt(self) -> str:
138
+ """Return the struct format character for an address/offset."""
139
+ return "Q" if self._ei_class == _ELFCLASS64 else "I"
140
+
141
+ def _ptr_size(self) -> int:
142
+ return 8 if self._ei_class == _ELFCLASS64 else 4
143
+
144
+ def _read_at(self, f: BinaryIO, offset: int, size: int) -> bytes:
145
+ """Seek to *offset* and read exactly *size* bytes."""
146
+ f.seek(offset)
147
+ data = f.read(size)
148
+ if len(data) < size:
149
+ raise ELFParseError(
150
+ f"Truncated read at offset {offset}: expected {size} bytes, got {len(data)}"
151
+ )
152
+ return data
153
+
154
+ def _read_string(self, f: BinaryIO, strtab_offset: int, str_offset: int) -> str:
155
+ """Read a null-terminated string from a string table."""
156
+ f.seek(strtab_offset + str_offset)
157
+ chunks: List[bytes] = []
158
+ while True:
159
+ byte = f.read(1)
160
+ if not byte or byte == b"\x00":
161
+ break
162
+ chunks.append(byte)
163
+ return b"".join(chunks).decode("ascii", errors="replace")
164
+
165
+ # ------------------------------------------------------------------
166
+ # Header parsing
167
+ # ------------------------------------------------------------------
168
+
169
+ def _parse_ident(self, f: BinaryIO) -> None:
170
+ """Parse the 16-byte ELF identification."""
171
+ ident = self._read_at(f, 0, 16)
172
+ if ident[:4] != _ELF_MAGIC:
173
+ raise ELFParseError(f"Not an ELF file: {self._path}")
174
+
175
+ self._ei_class = ident[4]
176
+ if self._ei_class not in (_ELFCLASS32, _ELFCLASS64):
177
+ raise ELFParseError(f"Unknown ELF class: {self._ei_class}")
178
+
179
+ self._ei_data = ident[5]
180
+ if self._ei_data not in (_ELFDATA2LSB, _ELFDATA2MSB):
181
+ raise ELFParseError(f"Unknown ELF data encoding: {self._ei_data}")
182
+
183
+ def _parse_header(self, f: BinaryIO) -> None:
184
+ """Parse the ELF header (after ident)."""
185
+ ep = self._endian_prefix()
186
+
187
+ # e_type (2 bytes at offset 16), e_machine (2 bytes at offset 18)
188
+ hdr_common = self._read_at(f, 16, 4)
189
+ self._e_type, self._e_machine = struct.unpack(ep + "HH", hdr_common)
190
+
191
+ if self._ei_class == _ELFCLASS64:
192
+ # 64-bit header layout (after e_ident[16]):
193
+ # e_type(2) e_machine(2) e_version(4) e_entry(8) e_phoff(8)
194
+ # e_shoff(8) e_flags(4) e_ehsize(2) e_phentsize(2) e_phnum(2)
195
+ # e_shentsize(2) e_shnum(2) e_shstrndx(2)
196
+ data = self._read_at(f, 16, 48) # offsets 16..63
197
+ (
198
+ _e_type, _e_machine, _e_version,
199
+ _e_entry, e_phoff, e_shoff,
200
+ _e_flags, _e_ehsize,
201
+ e_phentsize, e_phnum,
202
+ e_shentsize, e_shnum, e_shstrndx,
203
+ ) = struct.unpack(ep + "HHIQQQIHHHHHH", data)
204
+ else:
205
+ # 32-bit header layout (after e_ident[16]):
206
+ # e_type(2) e_machine(2) e_version(4) e_entry(4) e_phoff(4)
207
+ # e_shoff(4) e_flags(4) e_ehsize(2) e_phentsize(2) e_phnum(2)
208
+ # e_shentsize(2) e_shnum(2) e_shstrndx(2)
209
+ data = self._read_at(f, 16, 36) # offsets 16..51
210
+ (
211
+ _e_type, _e_machine, _e_version,
212
+ _e_entry, e_phoff, e_shoff,
213
+ _e_flags, _e_ehsize,
214
+ e_phentsize, e_phnum,
215
+ e_shentsize, e_shnum, e_shstrndx,
216
+ ) = struct.unpack(ep + "HHIIIIIHHHHHH", data)
217
+
218
+ self._e_shoff = e_shoff
219
+ self._e_shentsize = e_shentsize
220
+ self._e_shnum = e_shnum
221
+ self._e_shstrndx = e_shstrndx
222
+ self._e_phoff = e_phoff
223
+ self._e_phentsize = e_phentsize
224
+ self._e_phnum = e_phnum
225
+
226
+ # ------------------------------------------------------------------
227
+ # Program headers
228
+ # ------------------------------------------------------------------
229
+
230
+ def _parse_program_headers(self, f: BinaryIO) -> None:
231
+ """Parse all program headers to locate the PT_DYNAMIC segment."""
232
+ ep = self._endian_prefix()
233
+ for i in range(self._e_phnum):
234
+ offset = self._e_phoff + i * self._e_phentsize
235
+ if self._ei_class == _ELFCLASS64:
236
+ # Elf64_Phdr: p_type(4) p_flags(4) p_offset(8) p_vaddr(8)
237
+ # p_paddr(8) p_filesz(8) p_memsz(8) p_align(8)
238
+ data = self._read_at(f, offset, 56)
239
+ (
240
+ p_type, p_flags, p_offset, p_vaddr,
241
+ p_paddr, p_filesz, p_memsz, p_align,
242
+ ) = struct.unpack(ep + "IIQQQQQQ", data)
243
+ else:
244
+ # Elf32_Phdr: p_type(4) p_offset(4) p_vaddr(4) p_paddr(4)
245
+ # p_filesz(4) p_memsz(4) p_flags(4) p_align(4)
246
+ data = self._read_at(f, offset, 32)
247
+ (
248
+ p_type, p_offset, p_vaddr, p_paddr,
249
+ p_filesz, p_memsz, p_flags, p_align,
250
+ ) = struct.unpack(ep + "IIIIIIII", data)
251
+ self._phdrs.append({
252
+ "p_type": p_type,
253
+ "p_offset": p_offset,
254
+ "p_vaddr": p_vaddr,
255
+ "p_filesz": p_filesz,
256
+ "p_memsz": p_memsz,
257
+ })
258
+
259
+ # ------------------------------------------------------------------
260
+ # Section headers
261
+ # ------------------------------------------------------------------
262
+
263
+ def _parse_section_headers(self, f: BinaryIO) -> None:
264
+ """Parse all section headers."""
265
+ ep = self._endian_prefix()
266
+
267
+ for i in range(self._e_shnum):
268
+ offset = self._e_shoff + i * self._e_shentsize
269
+
270
+ if self._ei_class == _ELFCLASS64:
271
+ # Elf64_Shdr: sh_name(4) sh_type(4) sh_flags(8) sh_addr(8)
272
+ # sh_offset(8) sh_size(8) sh_link(4) sh_info(4)
273
+ # sh_addralign(8) sh_entsize(8)
274
+ data = self._read_at(f, offset, 64)
275
+ (
276
+ sh_name, sh_type, sh_flags, sh_addr,
277
+ sh_offset, sh_size, sh_link, sh_info,
278
+ sh_addralign, sh_entsize,
279
+ ) = struct.unpack(ep + "IIQQQQIIqq", data)
280
+ else:
281
+ # Elf32_Shdr: sh_name(4) sh_type(4) sh_flags(4) sh_addr(4)
282
+ # sh_offset(4) sh_size(4) sh_link(4) sh_info(4)
283
+ # sh_addralign(4) sh_entsize(4)
284
+ data = self._read_at(f, offset, 40)
285
+ (
286
+ sh_name, sh_type, sh_flags, sh_addr,
287
+ sh_offset, sh_size, sh_link, sh_info,
288
+ sh_addralign, sh_entsize,
289
+ ) = struct.unpack(ep + "IIIIIIIIII", data)
290
+
291
+ self._sections.append({
292
+ "sh_name": sh_name,
293
+ "sh_type": sh_type,
294
+ "sh_flags": sh_flags,
295
+ "sh_addr": sh_addr,
296
+ "sh_offset": sh_offset,
297
+ "sh_size": sh_size,
298
+ "sh_link": sh_link,
299
+ "sh_info": sh_info,
300
+ "sh_addralign": sh_addralign,
301
+ "sh_entsize": sh_entsize,
302
+ })
303
+
304
+ # Load the section header string table so we can resolve names
305
+ if 0 <= self._e_shstrndx < len(self._sections):
306
+ shstrtab = self._sections[self._e_shstrndx]
307
+ self._shstrtab_offset = shstrtab["sh_offset"]
308
+ self._shstrtab_size = shstrtab["sh_size"]
309
+
310
+ def _section_name(self, f: BinaryIO, name_offset: int) -> str:
311
+ """Resolve a section name from the section header string table."""
312
+ if self._shstrtab_offset == 0:
313
+ return ""
314
+ return self._read_string(f, self._shstrtab_offset, name_offset)
315
+
316
+ # ------------------------------------------------------------------
317
+ # Dynamic section parsing
318
+ # ------------------------------------------------------------------
319
+
320
+ def _parse_dynamic_section(self, f: BinaryIO) -> None:
321
+ """Parse the SHT_DYNAMIC section to extract DT_NEEDED, etc.
322
+
323
+ We locate the dynamic string table (DT_STRTAB/DT_STRSZ) and then
324
+ find the .dynstr section that covers that address range.
325
+ """
326
+ ep = self._endian_prefix()
327
+ ptr = self._ptr_fmt()
328
+ ps = self._ptr_size()
329
+
330
+ dyn_section = None
331
+ for sec in self._sections:
332
+ if sec["sh_type"] == _SHT_DYNAMIC:
333
+ dyn_section = sec
334
+ break
335
+
336
+ if dyn_section is None:
337
+ return
338
+
339
+ # Each dynamic entry is two pointer-width values: d_tag, d_un
340
+ entry_size = ps * 2
341
+ fmt = ep + ptr + ptr
342
+ count = dyn_section["sh_size"] // entry_size
343
+
344
+ dyn_data = self._read_at(f, dyn_section["sh_offset"], dyn_section["sh_size"])
345
+
346
+ dt_strtab_vaddr: int = 0
347
+ dt_strsz: int = 0
348
+
349
+ for i in range(count):
350
+ d_tag, d_val = struct.unpack_from(fmt, dyn_data, i * entry_size)
351
+ if d_tag == _DT_NULL:
352
+ break
353
+ elif d_tag == _DT_NEEDED:
354
+ self._dt_needed_offsets.append(d_val)
355
+ elif d_tag == _DT_SONAME:
356
+ self._dt_soname_offset = d_val
357
+ elif d_tag == _DT_RPATH:
358
+ self._dt_rpath_offset = d_val
359
+ elif d_tag == _DT_RUNPATH:
360
+ self._dt_runpath_offset = d_val
361
+ elif d_tag == _DT_STRTAB:
362
+ dt_strtab_vaddr = d_val
363
+ elif d_tag == _DT_STRSZ:
364
+ dt_strsz = d_val
365
+
366
+ # Resolve DT_STRTAB virtual address to a file offset by finding the
367
+ # matching section. The dynamic string table section (usually
368
+ # .dynstr) has type SHT_STRTAB and its sh_addr matches DT_STRTAB.
369
+ # If we can't resolve via sections, try program headers.
370
+ self._dynstr_offset = 0
371
+ self._dynstr_size = dt_strsz
372
+
373
+ if dt_strtab_vaddr != 0:
374
+ # First try: match a STRTAB section by virtual address
375
+ for sec in self._sections:
376
+ if sec["sh_type"] == _SHT_STRTAB and sec["sh_addr"] == dt_strtab_vaddr:
377
+ self._dynstr_offset = sec["sh_offset"]
378
+ if dt_strsz == 0:
379
+ self._dynstr_size = sec["sh_size"]
380
+ break
381
+
382
+ # Fallback: use the linked section from the dynamic section header
383
+ if self._dynstr_offset == 0 and dyn_section["sh_link"] < len(self._sections):
384
+ linked = self._sections[dyn_section["sh_link"]]
385
+ self._dynstr_offset = linked["sh_offset"]
386
+ if self._dynstr_size == 0:
387
+ self._dynstr_size = linked["sh_size"]
388
+
389
+ # Last resort: compute offset from PT_LOAD segments
390
+ if self._dynstr_offset == 0:
391
+ self._dynstr_offset = self._vaddr_to_offset(dt_strtab_vaddr)
392
+
393
+ def _vaddr_to_offset(self, vaddr: int) -> int:
394
+ """Convert a virtual address to a file offset using program headers.
395
+
396
+ Iterates over program headers to find a LOAD segment that contains
397
+ *vaddr* and computes the corresponding file offset. Returns 0 if
398
+ no matching segment is found.
399
+ """
400
+ # PT_LOAD = 1
401
+ for phdr in self._phdrs:
402
+ if phdr["p_type"] != 1: # PT_LOAD
403
+ continue
404
+ seg_vaddr = phdr["p_vaddr"]
405
+ seg_memsz = phdr["p_memsz"]
406
+ if seg_vaddr <= vaddr < seg_vaddr + seg_memsz:
407
+ return phdr["p_offset"] + (vaddr - seg_vaddr)
408
+ return 0
409
+
410
+ def _resolve_dynamic_strings(self, f: BinaryIO) -> None:
411
+ """Read actual string values from the dynstr table."""
412
+ if self._dynstr_offset == 0:
413
+ return
414
+
415
+ for off in self._dt_needed_offsets:
416
+ self._needed.append(self._read_string(f, self._dynstr_offset, off))
417
+
418
+ if self._dt_soname_offset is not None:
419
+ self._soname = self._read_string(f, self._dynstr_offset, self._dt_soname_offset)
420
+
421
+ if self._dt_rpath_offset is not None:
422
+ self._rpath = self._read_string(f, self._dynstr_offset, self._dt_rpath_offset)
423
+
424
+ if self._dt_runpath_offset is not None:
425
+ self._runpath = self._read_string(f, self._dynstr_offset, self._dt_runpath_offset)
426
+
427
+ # ------------------------------------------------------------------
428
+ # GNU version requirements (SHT_GNU_VERNEED)
429
+ # ------------------------------------------------------------------
430
+
431
+ def _parse_verneed(self, f: BinaryIO) -> None:
432
+ """Parse .gnu.version_r to extract GLIBC/GLIBCXX version strings.
433
+
434
+ The section contains a linked list of ``Elfxx_Verneed`` entries.
435
+ Each entry has a linked list of ``Elfxx_Vernaux`` sub-entries that
436
+ carry the actual version string indices.
437
+
438
+ Verneed layout (both 32- and 64-bit have same sizes):
439
+ vn_version (2) -- always 1
440
+ vn_cnt (2) -- number of Vernaux entries
441
+ vn_file (4) -- offset into linked string table for library name
442
+ vn_aux (4) -- byte offset to first Vernaux
443
+ vn_next (4) -- byte offset to next Verneed (0 = last)
444
+
445
+ Vernaux layout:
446
+ vna_hash (4)
447
+ vna_flags (2)
448
+ vna_other (2)
449
+ vna_name (4) -- offset into linked string table for version string
450
+ vna_next (4) -- byte offset to next Vernaux (0 = last)
451
+ """
452
+ ep = self._endian_prefix()
453
+
454
+ for sec in self._sections:
455
+ if sec["sh_type"] != _SHT_GNU_VERNEED:
456
+ continue
457
+
458
+ # The linked string table is indicated by sh_link
459
+ strtab_sec_idx = sec["sh_link"]
460
+ if strtab_sec_idx >= len(self._sections):
461
+ continue
462
+ strtab_offset = self._sections[strtab_sec_idx]["sh_offset"]
463
+
464
+ sec_data = self._read_at(f, sec["sh_offset"], sec["sh_size"])
465
+ pos = 0
466
+
467
+ while pos < len(sec_data):
468
+ if pos + 16 > len(sec_data):
469
+ break
470
+
471
+ vn_version, vn_cnt, vn_file, vn_aux, vn_next = struct.unpack_from(
472
+ ep + "HHIII", sec_data, pos
473
+ )
474
+
475
+ lib_name = self._read_string(f, strtab_offset, vn_file)
476
+
477
+ # Walk Vernaux entries
478
+ aux_pos = pos + vn_aux
479
+ for _ in range(vn_cnt):
480
+ if aux_pos + 16 > len(sec_data):
481
+ break
482
+ vna_hash, vna_flags, vna_other, vna_name, vna_next = struct.unpack_from(
483
+ ep + "IHHII", sec_data, aux_pos
484
+ )
485
+ ver_str = self._read_string(f, strtab_offset, vna_name)
486
+ self._version_requirements.append((lib_name, ver_str))
487
+
488
+ if vna_next == 0:
489
+ break
490
+ aux_pos += vna_next
491
+
492
+ if vn_next == 0:
493
+ break
494
+ pos += vn_next
495
+
496
+ # ------------------------------------------------------------------
497
+ # GNU build-id (SHT_NOTE with name "GNU\0" and type NT_GNU_BUILD_ID)
498
+ # ------------------------------------------------------------------
499
+
500
+ def _parse_build_id(self, f: BinaryIO) -> None:
501
+ """Parse .note.gnu.build-id section."""
502
+ ep = self._endian_prefix()
503
+
504
+ for sec in self._sections:
505
+ if sec["sh_type"] != _SHT_NOTE:
506
+ continue
507
+
508
+ # Check the section name to avoid parsing irrelevant notes
509
+ name = self._section_name(f, sec["sh_name"])
510
+ if name != ".note.gnu.build-id":
511
+ continue
512
+
513
+ note_data = self._read_at(f, sec["sh_offset"], sec["sh_size"])
514
+ pos = 0
515
+
516
+ while pos + 12 <= len(note_data):
517
+ namesz, descsz, note_type = struct.unpack_from(ep + "III", note_data, pos)
518
+ pos += 12
519
+
520
+ # Align name and desc to 4 bytes
521
+ namesz_aligned = (namesz + 3) & ~3
522
+ descsz_aligned = (descsz + 3) & ~3
523
+
524
+ if pos + namesz_aligned + descsz_aligned > len(note_data):
525
+ break
526
+
527
+ note_name = note_data[pos:pos + namesz].rstrip(b"\x00")
528
+ pos += namesz_aligned
529
+
530
+ if note_name == b"GNU" and note_type == _NT_GNU_BUILD_ID:
531
+ desc = note_data[pos:pos + descsz]
532
+ self._build_id = desc.hex()
533
+ return
534
+
535
+ pos += descsz_aligned
536
+
537
+ # ------------------------------------------------------------------
538
+ # Public API
539
+ # ------------------------------------------------------------------
540
+
541
+ def parse(self) -> Dict[str, object]:
542
+ """Parse the ELF file and return extracted metadata.
543
+
544
+ Returns
545
+ -------
546
+ dict
547
+ Keys: ``ei_class``, ``endianness``, ``e_machine``,
548
+ ``e_type``, ``needed``, ``soname``, ``rpath``, ``runpath``,
549
+ ``version_requirements``, ``build_id``.
550
+
551
+ Raises
552
+ ------
553
+ ELFParseError
554
+ If the file is not a valid ELF binary or is truncated.
555
+ OSError
556
+ If the file cannot be opened.
557
+ """
558
+ with open(self._path, "rb") as f:
559
+ self._parse_ident(f)
560
+ self._parse_header(f)
561
+ self._parse_program_headers(f)
562
+ self._parse_section_headers(f)
563
+ self._parse_dynamic_section(f)
564
+ self._resolve_dynamic_strings(f)
565
+ self._parse_verneed(f)
566
+ self._parse_build_id(f)
567
+
568
+ self._parsed = True
569
+
570
+ return {
571
+ "ei_class": 64 if self._ei_class == _ELFCLASS64 else 32,
572
+ "endianness": "little" if self._ei_data == _ELFDATA2LSB else "big",
573
+ "e_machine": self._e_machine,
574
+ "e_type": self._e_type,
575
+ "needed": list(self._needed),
576
+ "soname": self._soname,
577
+ "rpath": self._rpath,
578
+ "runpath": self._runpath,
579
+ "version_requirements": list(self._version_requirements),
580
+ "build_id": self._build_id,
581
+ }
582
+
583
+ def get_needed(self) -> List[str]:
584
+ """Return the list of DT_NEEDED library names.
585
+
586
+ ``parse()`` must be called first.
587
+ """
588
+ return list(self._needed)
589
+
590
+ def get_version_requirements(self) -> List[Tuple[str, str]]:
591
+ """Return GNU version requirements as ``(library, version)`` tuples.
592
+
593
+ ``parse()`` must be called first.
594
+ """
595
+ return list(self._version_requirements)
596
+
597
+
598
+ # ---------------------------------------------------------------------------
599
+ # ELF Analyzer
600
+ # ---------------------------------------------------------------------------
601
+
602
+
603
+ class ELFAnalyzer(AnalyzerBase):
604
+ """Analyzer that discovers and parses ELF shared objects in a package.
605
+
606
+ For each ``.so`` file found under the package's ``install_path``:
607
+
608
+ 1. Parse the ELF header and dynamic section with ``MinimalELFParser``.
609
+ 2. Populate a ``SharedObjectInfo`` instance.
610
+ 3. Set package-level aggregates (``required_glibc``,
611
+ ``target_architecture``, ``required_libraries``).
612
+ """
613
+
614
+ name: str = "elf"
615
+
616
+ def analyze(self, package_info: PackageBinaryInfo) -> PackageBinaryInfo:
617
+ """Discover and parse all ELF shared objects under *package_info.install_path*.
618
+
619
+ Parameters
620
+ ----------
621
+ package_info:
622
+ The package descriptor to populate.
623
+
624
+ Returns
625
+ -------
626
+ PackageBinaryInfo
627
+ The same (mutated) instance.
628
+ """
629
+ install_path = package_info.install_path
630
+ if not install_path or not os.path.isdir(install_path):
631
+ return package_info
632
+
633
+ so_paths = self._find_shared_objects(install_path)
634
+ if not so_paths:
635
+ return package_info
636
+
637
+ package_info.is_pure_python = False
638
+ all_needed: set = set() # type: ignore[type-arg]
639
+ max_glibc: Optional[Tuple[int, int]] = None
640
+ arch: Optional[Architecture] = None
641
+
642
+ for so_path in so_paths:
643
+ so_info = self._parse_so(so_path)
644
+ if so_info is None:
645
+ continue
646
+
647
+ package_info.shared_objects.append(so_info)
648
+
649
+ # Aggregate DT_NEEDED
650
+ for lib in so_info.dt_needed:
651
+ all_needed.add(lib)
652
+
653
+ # Track maximum GLIBC requirement
654
+ if so_info.required_glibc is not None:
655
+ if max_glibc is None or so_info.required_glibc > max_glibc:
656
+ max_glibc = so_info.required_glibc
657
+
658
+ # Architecture (use the first one found; they should all agree)
659
+ if arch is None and so_info.architecture != Architecture.UNKNOWN:
660
+ arch = so_info.architecture
661
+
662
+ package_info.required_libraries = all_needed
663
+ if max_glibc is not None:
664
+ package_info.required_glibc = max_glibc
665
+ if arch is not None:
666
+ package_info.target_architecture = arch
667
+
668
+ return package_info
669
+
670
+ @staticmethod
671
+ def _find_shared_objects(root: str) -> List[str]:
672
+ """Walk *root* and collect paths to all ``.so`` files."""
673
+ result: List[str] = []
674
+ try:
675
+ for dirpath, _dirnames, filenames in os.walk(root):
676
+ for fname in filenames:
677
+ if fname.endswith(".so") or ".so." in fname:
678
+ result.append(os.path.join(dirpath, fname))
679
+ except OSError as exc:
680
+ logger.debug("Error walking %s: %s", root, exc)
681
+ return result
682
+
683
+ @staticmethod
684
+ def _parse_so(path: str) -> Optional[SharedObjectInfo]:
685
+ """Parse a single .so file and return its ``SharedObjectInfo``.
686
+
687
+ Returns ``None`` if the file cannot be parsed (e.g. not ELF, or I/O
688
+ error).
689
+ """
690
+ try:
691
+ parser = MinimalELFParser(path)
692
+ info = parser.parse()
693
+ except (ELFParseError, OSError) as exc:
694
+ logger.debug("Skipping %s: %s", path, exc)
695
+ return None
696
+
697
+ architecture = _EM_TO_ARCH.get(info["e_machine"], Architecture.UNKNOWN) # type: ignore[arg-type]
698
+
699
+ # Compute the maximum GLIBC version from version requirements
700
+ max_glibc: Optional[Tuple[int, int]] = None
701
+ version_req_strs: List[str] = []
702
+ for lib, ver_str in info["version_requirements"]: # type: ignore[union-attr]
703
+ version_req_strs.append(f"{lib}({ver_str})")
704
+ if ver_str.startswith("GLIBC_"):
705
+ glibc_ver = _parse_glibc_version(ver_str)
706
+ if glibc_ver is not None:
707
+ if max_glibc is None or glibc_ver > max_glibc:
708
+ max_glibc = glibc_ver
709
+
710
+ # Detect max GLIBCXX
711
+ max_glibcxx: Optional[str] = None
712
+ for lib, ver_str in info["version_requirements"]: # type: ignore[union-attr]
713
+ if ver_str.startswith("GLIBCXX_"):
714
+ if max_glibcxx is None or ver_str > max_glibcxx:
715
+ max_glibcxx = ver_str
716
+
717
+ try:
718
+ file_size = os.path.getsize(path)
719
+ except OSError:
720
+ file_size = 0
721
+
722
+ return SharedObjectInfo(
723
+ path=path,
724
+ filename=os.path.basename(path),
725
+ architecture=architecture,
726
+ elf_class=info["ei_class"], # type: ignore[arg-type]
727
+ endianness=info["endianness"], # type: ignore[arg-type]
728
+ dt_needed=info["needed"], # type: ignore[arg-type]
729
+ dt_soname=info["soname"], # type: ignore[arg-type]
730
+ dt_rpath=info["rpath"], # type: ignore[arg-type]
731
+ dt_runpath=info["runpath"], # type: ignore[arg-type]
732
+ required_glibc=max_glibc,
733
+ required_glibcxx=max_glibcxx,
734
+ gnu_version_requirements=version_req_strs,
735
+ build_id=info["build_id"], # type: ignore[arg-type]
736
+ file_size=file_size,
737
+ )
738
+
739
+
740
+ def _parse_glibc_version(version_string: str) -> Optional[Tuple[int, int]]:
741
+ """Parse a version string like ``GLIBC_2.17`` into ``(2, 17)``.
742
+
743
+ Returns ``None`` if the string cannot be parsed.
744
+ """
745
+ prefix = "GLIBC_"
746
+ if not version_string.startswith(prefix):
747
+ return None
748
+ parts = version_string[len(prefix):].split(".")
749
+ if len(parts) < 2:
750
+ return None
751
+ try:
752
+ return (int(parts[0]), int(parts[1]))
753
+ except (ValueError, IndexError):
754
+ return None