libidentifier 0.2.0rc1__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 (50) hide show
  1. libidentifier/__init__.py +36 -0
  2. libidentifier/api.py +90 -0
  3. libidentifier/archive.py +133 -0
  4. libidentifier/cli.py +340 -0
  5. libidentifier/cli_resources.py +36 -0
  6. libidentifier/cli_setup.py +177 -0
  7. libidentifier/config.py +54 -0
  8. libidentifier/detect.py +30 -0
  9. libidentifier/errors.py +30 -0
  10. libidentifier/extractors/__init__.py +2 -0
  11. libidentifier/extractors/dex.py +121 -0
  12. libidentifier/extractors/dex_tables.py +137 -0
  13. libidentifier/extractors/elf.py +66 -0
  14. libidentifier/extractors/instructions.py +77 -0
  15. libidentifier/extractors/metadata.py +99 -0
  16. libidentifier/extractors/version_metadata.py +72 -0
  17. libidentifier/fingerprint/__init__.py +2 -0
  18. libidentifier/fingerprint/dex_v2.py +97 -0
  19. libidentifier/fingerprint/elf_v1.py +16 -0
  20. libidentifier/grouping.py +74 -0
  21. libidentifier/inventory.py +260 -0
  22. libidentifier/matcher.py +381 -0
  23. libidentifier/models.py +423 -0
  24. libidentifier/profiles/__init__.py +2 -0
  25. libidentifier/profiles/builder.py +182 -0
  26. libidentifier/profiles/catalog.py +135 -0
  27. libidentifier/profiles/distribution.py +303 -0
  28. libidentifier/profiles/fetcher.py +141 -0
  29. libidentifier/profiles/pom.py +66 -0
  30. libidentifier/profiles/schema.py +137 -0
  31. libidentifier/profiles/store.py +212 -0
  32. libidentifier/purl.py +36 -0
  33. libidentifier/relationships.py +168 -0
  34. libidentifier/sbom.py +62 -0
  35. libidentifier/serialization.py +53 -0
  36. libidentifier/table.py +254 -0
  37. libidentifier/version.py +2 -0
  38. libidentifier/vulnerabilities/__init__.py +2 -0
  39. libidentifier/vulnerabilities/correlate.py +164 -0
  40. libidentifier/vulnerabilities/database.py +213 -0
  41. libidentifier/vulnerabilities/models.py +155 -0
  42. libidentifier/vulnerabilities/runner.py +148 -0
  43. libidentifier/vulnerabilities/setup.py +74 -0
  44. libidentifier-0.2.0rc1.dist-info/METADATA +156 -0
  45. libidentifier-0.2.0rc1.dist-info/RECORD +50 -0
  46. libidentifier-0.2.0rc1.dist-info/WHEEL +5 -0
  47. libidentifier-0.2.0rc1.dist-info/entry_points.txt +2 -0
  48. libidentifier-0.2.0rc1.dist-info/licenses/LICENSE +62 -0
  49. libidentifier-0.2.0rc1.dist-info/licenses/THIRD_PARTY_NOTICES.md +15 -0
  50. libidentifier-0.2.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,177 @@
1
+ """Consent-based setup for the managed CLI scan path; analysis APIs never call it."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ import tempfile
8
+
9
+ from .cli_resources import supported_scanner
10
+ from .errors import AdvisoryError, InputError, ProfileError
11
+ from .profiles.builder import validate_database
12
+ from .profiles.distribution import install_profiles, _read_url
13
+ from .profiles.store import sha256_file
14
+ from .vulnerabilities.database import update_advisory_database, validate_advisory_database
15
+ from .vulnerabilities.models import VulnerabilityOptions
16
+ from .vulnerabilities.runner import verify_scanner
17
+ from .vulnerabilities.setup import discover_scanner
18
+
19
+
20
+ def resource_home():
21
+ if override := os.environ.get("LIBIDENTIFIER_HOME"):
22
+ return Path(override).expanduser().resolve()
23
+ if os.name == "nt":
24
+ base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData/Local")
25
+ return base / "LibIdentifier"
26
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "libidentifier"
27
+
28
+
29
+ def _step(label, operation):
30
+ print(label + "...", end=" ", file=sys.stderr, flush=True)
31
+ try:
32
+ value = operation()
33
+ except Exception:
34
+ print("failed", file=sys.stderr)
35
+ raise
36
+ print("done", file=sys.stderr)
37
+ return value
38
+
39
+
40
+ def _consent(args, missing, root):
41
+ print("\nFirst-time setup\n\nThe following resources are missing:", file=sys.stderr)
42
+ for name in missing:
43
+ print(" - " + name, file=sys.stderr)
44
+ print(f"\nDownloads will be stored in:\n {root}\n\n"
45
+ "Your APK will stay on this computer.\n"
46
+ "Installed resources will be reused for future scans.\n", file=sys.stderr)
47
+ if args.vulnerabilities and args.osv_db and not Path(args.osv_db).is_relative_to(root):
48
+ print(f"Advisory database override:\n {args.osv_db}\n", file=sys.stderr)
49
+ if args.offline:
50
+ print("Offline mode: downloads are prohibited.", file=sys.stderr)
51
+ return False
52
+ if args.yes:
53
+ return True
54
+ if not sys.stdin.isatty():
55
+ print("Setup requires an interactive terminal or --yes; no resources downloaded.", file=sys.stderr)
56
+ return False
57
+ print("Download and set up these resources? [y/N]: ", end="", file=sys.stderr, flush=True)
58
+ try:
59
+ answer = sys.stdin.readline().strip().lower()
60
+ except (EOFError, OSError):
61
+ return False
62
+ return answer in {"y", "yes"}
63
+
64
+
65
+ def _options(args, scanner):
66
+ return VulnerabilityOptions(scanner, args.osv_db, scanner_sha256=args.osv_scanner_sha256,
67
+ timeout_seconds=args.osv_timeout)
68
+
69
+
70
+ def _verify(args, scanner):
71
+ return verify_scanner(_options(args, scanner))
72
+
73
+
74
+ def _install_scanner(args, release, target):
75
+ target.parent.mkdir(parents=True, exist_ok=True)
76
+ try:
77
+ with tempfile.TemporaryDirectory(prefix=".download-", dir=target.parent) as temporary:
78
+ candidate = Path(temporary) / release.filename
79
+ def download():
80
+ with candidate.open("wb") as output:
81
+ _read_url(release.url, output, limit=128 * 1024 * 1024, timeout=args.osv_timeout)
82
+ _step("Downloading OSV-Scanner", download)
83
+ def checksum():
84
+ if sha256_file(candidate) != release.sha256:
85
+ raise AdvisoryError("Downloaded OSV-Scanner checksum does not match the project pin",
86
+ code="scanner_unapproved")
87
+ _step("Verifying scanner checksum", checksum)
88
+ candidate.chmod(0o755)
89
+ _step("Checking scanner compatibility", lambda: _verify(args, candidate))
90
+ if os.name == "nt":
91
+ os.rename(candidate, target) # Refuses a concurrently created destination.
92
+ else:
93
+ os.link(candidate, target)
94
+ except ProfileError as error:
95
+ raise AdvisoryError(str(error), code="scanner_download_failed") from error
96
+
97
+
98
+ def prepare_managed_scan(args):
99
+ """Resolve resources for scan without --profiles/environment; return profile path.
100
+
101
+ Consent applies only to missing resources. Existing invalid resources require
102
+ explicit maintenance. Optional setup failures preserve library identification.
103
+ """
104
+ if not Path(args.path).exists():
105
+ raise InputError(f"input does not exist: {args.path}")
106
+ root = resource_home()
107
+ profiles = root / "profiles/profiles.sqlite"
108
+ profile_missing = not profiles.exists()
109
+ if not profile_missing:
110
+ validate_database(profiles)
111
+ missing = ["Library profiles: official preview snapshot"] if profile_missing else []
112
+ scanner_missing = database_missing = False
113
+ release = None
114
+ args._setup_error = None
115
+ if args.vulnerabilities:
116
+ args.osv_db = str(Path(args.osv_db).resolve() if args.osv_db else root / "advisory-db")
117
+ try:
118
+ if args.osv_scanner:
119
+ args.osv_scanner = str(Path(args.osv_scanner).resolve())
120
+ _verify(args, args.osv_scanner)
121
+ else:
122
+ release = supported_scanner()
123
+ if args.osv_scanner_sha256 and args.osv_scanner_sha256.lower() != release.sha256:
124
+ raise AdvisoryError("Requested hash disagrees with the managed scanner pin; "
125
+ "use --osv-scanner for an explicit override", code="scanner_unapproved")
126
+ args.osv_scanner_sha256 = release.sha256
127
+ target = root / "tools/osv-scanner" / release.version / release.filename
128
+ # Reuse a discovered executable only if its bytes match the project pin.
129
+ if not target.exists():
130
+ try:
131
+ discovered = discover_scanner()
132
+ if sha256_file(discovered) == release.sha256:
133
+ target = discovered
134
+ except (AdvisoryError, OSError):
135
+ pass
136
+ args.osv_scanner = str(target)
137
+ scanner_missing = not target.exists()
138
+ if scanner_missing:
139
+ missing.append(f"OSV-Scanner: project-supported {release.filename} ({release.version})")
140
+ else:
141
+ _verify(args, target) # Includes hash validation before execution.
142
+ database_missing = not Path(args.osv_db).exists()
143
+ if database_missing:
144
+ missing.append("Maven vulnerability database: current snapshot")
145
+ except (AdvisoryError, OSError, ValueError) as error:
146
+ args._setup_error = error if isinstance(error, AdvisoryError) else AdvisoryError(str(error), code="setup_failed")
147
+
148
+ accepted = _consent(args, missing, root) if missing else False
149
+ if profile_missing:
150
+ if not accepted:
151
+ raise ProfileError("Library profiles are required. Run scan interactively or with --yes, "
152
+ "or supply local --profiles. --offline prohibits setup downloads.")
153
+ _step("Downloading and validating library profiles", lambda: install_profiles(database=profiles))
154
+ if args.vulnerabilities and args._setup_error is None:
155
+ optional_missing = scanner_missing or database_missing
156
+ if optional_missing and not accepted:
157
+ args._setup_error = AdvisoryError("Vulnerability setup was not authorized or resources are missing "
158
+ "in offline mode; identification is retained", code="setup_not_performed")
159
+ elif optional_missing:
160
+ try:
161
+ if scanner_missing:
162
+ _install_scanner(args, release, Path(args.osv_scanner))
163
+ # Never refresh any existing directory, even an empty/corrupt one.
164
+ if database_missing and not Path(args.osv_db).exists():
165
+ _step("Downloading vulnerability data", lambda: update_advisory_database(
166
+ args.osv_db, timeout_seconds=args.osv_timeout))
167
+ _step("Validating vulnerability data", lambda: validate_advisory_database(
168
+ args.osv_db, max_age_days=args.max_database_age_days))
169
+ except (AdvisoryError, ProfileError, OSError, ValueError) as error:
170
+ args._setup_error = error if isinstance(error, AdvisoryError) else AdvisoryError(str(error), code="setup_failed")
171
+ # The single consent decision is final. No later assessment path may provision.
172
+ args.offline = True
173
+ if accepted:
174
+ print("\nSetup complete." if args._setup_error is None else
175
+ "\nLibrary profiles ready; vulnerability setup incomplete. Identification will continue.",
176
+ file=sys.stderr)
177
+ return profiles
@@ -0,0 +1,54 @@
1
+ """Configuration knobs for scanning and matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class IdentifyOptions:
10
+ max_archive_members: int = 20_000
11
+ max_member_uncompressed_bytes: int = 128 * 1024 * 1024
12
+ max_total_selected_uncompressed_bytes: int = 512 * 1024 * 1024
13
+ max_compression_ratio: int = 200
14
+ max_directory_files: int = 20_000
15
+ max_directory_depth: int = 8
16
+ max_scan_seconds: int = 180 # Compatibility-only: not enforced in-process; use a host subprocess timeout.
17
+ max_emitted_unknown_classes: int | None = None
18
+ medium_min_discriminative_classes: int = 3
19
+ medium_min_weighted_coverage: float = 0.75
20
+ medium_partial_min_exact_classes: int = 25
21
+ medium_partial_min_weighted_coverage: float = 0.65
22
+ medium_partial_min_unique_anchors: int = 10
23
+ high_min_discriminative_classes: int = 5
24
+ high_min_weighted_coverage: float = 0.90
25
+ high_min_unique_anchors: int = 2
26
+ high_min_runner_up_delta: float = 0.15
27
+ min_material_runner_overlap: float = 0.25
28
+ max_version_candidates: int = 12
29
+ extract_relationships: bool = True
30
+ extract_calls: bool = True
31
+ group_unknown_classes: bool = True
32
+
33
+
34
+ def __post_init__(self):
35
+ for name in ('extract_relationships', 'extract_calls', 'group_unknown_classes'):
36
+ if not isinstance(getattr(self, name), bool):
37
+ raise ValueError(f'{name} must be boolean')
38
+ for name in ("max_archive_members", "max_member_uncompressed_bytes",
39
+ "max_total_selected_uncompressed_bytes", "max_compression_ratio",
40
+ "max_directory_files", "max_directory_depth"):
41
+ value = getattr(self, name)
42
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
43
+ raise ValueError(f"{name} must be a positive integer")
44
+ for name in ("max_emitted_unknown_classes", "max_version_candidates"):
45
+ value = getattr(self, name)
46
+ if name == "max_emitted_unknown_classes" and value is None:
47
+ continue
48
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
49
+ raise ValueError(f"{name} must be a non-negative integer")
50
+
51
+
52
+ FINGERPRINT_ALGORITHM = "dex-shape-v2-canonical+elf-identity-v1"
53
+ SCHEMA_VERSION = 1
54
+ DATABASE_SCHEMA_VERSION = 2
@@ -0,0 +1,30 @@
1
+ """Magic-byte format detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def detect_bytes(data: bytes, *, name: str | None = None) -> str:
9
+ if data.startswith(b"dex\n") and len(data) >= 8:
10
+ return "dex"
11
+ if data.startswith(b"\x7fELF"):
12
+ return "elf"
13
+ if data.startswith(b"PK\x03\x04") or data.startswith(b"PK\x05\x06") or data.startswith(b"PK\x07\x08"):
14
+ lowered = (name or "").lower()
15
+ if lowered.endswith(".apk"):
16
+ return "apk"
17
+ if lowered.endswith(".aar"):
18
+ return "aar"
19
+ if lowered.endswith(".jar"):
20
+ return "jar"
21
+ return "zip"
22
+ return "unknown"
23
+
24
+
25
+ def detect_file(path: str | Path, read_size: int = 8) -> str:
26
+ p = Path(path)
27
+ with p.open("rb") as fh:
28
+ head = fh.read(read_size)
29
+ return detect_bytes(head, name=p.name)
30
+
@@ -0,0 +1,30 @@
1
+ """Structured exceptions used by LibIdentifier."""
2
+
3
+
4
+ class LibIdentifierError(Exception):
5
+ """Base class for package errors."""
6
+
7
+
8
+ class InputError(LibIdentifierError):
9
+ """The top-level input cannot be inspected safely."""
10
+
11
+
12
+ class ProfileError(LibIdentifierError):
13
+ """The profile database is missing, incompatible, or corrupt."""
14
+
15
+
16
+ class ProfileBuildError(LibIdentifierError):
17
+ """A trusted reference profile could not be built."""
18
+
19
+
20
+ class ParserError(LibIdentifierError):
21
+ """A binary parser failed for a selected artifact."""
22
+
23
+
24
+ class AdvisoryError(LibIdentifierError):
25
+ """An advisory dependency or snapshot could not be used or provisioned."""
26
+ def __init__(self, message, *, code="advisory_error", database=None):
27
+ super().__init__(message)
28
+ self.code = code
29
+ self.database = database
30
+
@@ -0,0 +1,2 @@
1
+ """Binary feature extractors."""
2
+
@@ -0,0 +1,121 @@
1
+ """LIEF DEX feature extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ..models import CodeLocator
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class FieldFeature:
13
+ name: str
14
+ type_descriptor: str
15
+ access_flags: list[str]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class MethodFeature:
20
+ name: str
21
+ return_type: str
22
+ parameter_types: tuple[str, ...]
23
+ access_flags: list[str]
24
+ descriptor: str | None = None
25
+ method_index: int | None = None
26
+ code_offset: int | None = None
27
+ bytecode: bytes = b''
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ClassFeature:
32
+ descriptor: str
33
+ access_flags: list[str]
34
+ superclass: str | None
35
+ interfaces: tuple[str, ...]
36
+ fields: tuple[FieldFeature, ...]
37
+ methods: tuple[MethodFeature, ...]
38
+ source_filename: str | None
39
+ locator: CodeLocator
40
+
41
+
42
+ def _flags(value: Any) -> list[str]:
43
+ if value is None:
44
+ return []
45
+ if isinstance(value, int):
46
+ return [str(value)] if value else []
47
+ try:
48
+ iterable = list(value)
49
+ except TypeError:
50
+ return [str(value)]
51
+ return sorted(str(x).split(".")[-1].lower() for x in iterable)
52
+
53
+
54
+ def parse_dex(data: bytes, *, container: str, member: str | None, sha256: str, tables=None) -> list[ClassFeature]:
55
+ import lief
56
+ from .dex_tables import DexTables
57
+
58
+ tables = tables or DexTables(data)
59
+ defined_descriptors = set(tables.classes)
60
+ try:
61
+ parsed = lief.DEX.parse(list(data), name=member or container)
62
+ except TypeError:
63
+ parsed = lief.DEX.parse(list(data))
64
+ if parsed is None:
65
+ raise ValueError("LIEF returned no DEX object")
66
+ classes = []
67
+ for cls in getattr(parsed, "classes", []) or []:
68
+ descriptor = cls.fullname
69
+ if descriptor not in defined_descriptors:
70
+ continue
71
+ superclass, interfaces = tables.classes[descriptor]
72
+ interfaces = tuple(sorted(interfaces))
73
+ fields = []
74
+ for field in getattr(cls, "fields", []) or []:
75
+ if field.index not in tables.defined_fields:
76
+ continue
77
+ fields.append(
78
+ FieldFeature(
79
+ name=str(getattr(field, "name", "")),
80
+ type_descriptor=tables.at(tables.fields, field.index)[1],
81
+ access_flags=_flags(getattr(field, "access_flags", None)),
82
+ )
83
+ )
84
+ methods = []
85
+ for method in getattr(cls, "methods", []) or []:
86
+ index = method.index
87
+ if index not in tables.defined_methods:
88
+ continue
89
+ _, _, (ret, params) = tables.at(tables.methods, index)
90
+ code_offset, bytecode = tables.code.get(index, (None, b''))
91
+ methods.append(
92
+ MethodFeature(
93
+ name=str(getattr(method, "name", "")),
94
+ return_type=ret,
95
+ parameter_types=params,
96
+ access_flags=_flags(getattr(method, "access_flags", None)),
97
+ descriptor=tables.method_descriptor(index),
98
+ method_index=index,
99
+ code_offset=code_offset,
100
+ bytecode=bytecode,
101
+ )
102
+ )
103
+ access_flags = _flags(getattr(cls, "access_flags", None))
104
+ # The raw class_defs table already proves this is an embedded definition.
105
+ # Package-private empty classes are valid and must remain in the inventory.
106
+ classes.append(
107
+ ClassFeature(
108
+ descriptor=descriptor,
109
+ access_flags=access_flags,
110
+ superclass=superclass,
111
+ interfaces=interfaces,
112
+ fields=tuple(sorted(fields, key=lambda f: (f.type_descriptor, f.name))),
113
+ methods=tuple(sorted(methods, key=lambda m: (m.name, m.return_type, m.parameter_types))),
114
+ source_filename=getattr(cls, "source_filename", None),
115
+ locator=CodeLocator(container=container, member=member, class_descriptor=descriptor, sha256=sha256),
116
+ )
117
+ )
118
+ if not classes:
119
+ raise ValueError("DEX contains no class definitions")
120
+ return sorted(classes, key=lambda c: c.descriptor)
121
+
@@ -0,0 +1,137 @@
1
+ """Bounded standard DEX tables supplementing LIEF's missing descriptor/interfaces API.
2
+
3
+ This reader never executes code. Offsets are absolute bytes; instructions are
4
+ retained as bytes and decoded separately. Unsupported container versions fail.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import struct
9
+
10
+
11
+ class DexTables:
12
+ def __init__(self, data: bytes):
13
+ self.data = data
14
+ self.require(0, 0x70)
15
+ if data[:8] not in {b'dex\n035\0', b'dex\n037\0', b'dex\n038\0', b'dex\n039\0', b'dex\n040\0'}:
16
+ raise ValueError('Unsupported standard DEX version')
17
+ if self.u32(0x28) != 0x12345678 or self.u32(0x24) != 0x70:
18
+ raise ValueError('Unsupported DEX header/endian format')
19
+ if self.u32(0x20) != len(data):
20
+ raise ValueError('DEX file size disagrees with available data')
21
+ self.strings = [self.string(self.u32(p)) for p in self.table(0x38, 4)]
22
+ self.types = [self.at(self.strings, self.u32(p)) for p in self.table(0x40, 4)]
23
+ self.protos = [(self.at(self.types, self.u32(p + 4)), self.type_list(self.u32(p + 8)))
24
+ for p in self.table(0x48, 12)]
25
+ self.fields = [(self.at(self.types, self.u16(p)), self.at(self.types, self.u16(p + 2)),
26
+ self.at(self.strings, self.u32(p + 4))) for p in self.table(0x50, 8)]
27
+ self.methods = [(self.at(self.types, self.u16(p)), self.at(self.strings, self.u32(p + 4)),
28
+ self.at(self.protos, self.u16(p + 2))) for p in self.table(0x58, 8)]
29
+ self.classes = {}
30
+ self.code = {}
31
+ self.defined_fields = set()
32
+ self.defined_methods = set()
33
+ for p in self.table(0x60, 32):
34
+ desc = self.at(self.types, self.u32(p))
35
+ if desc in self.classes:
36
+ raise ValueError('Duplicate DEX class definition')
37
+ parent = self.u32(p + 8)
38
+ self.classes[desc] = (None if parent == 0xffffffff else self.at(self.types, parent),
39
+ self.type_list(self.u32(p + 12)))
40
+ offset = self.u32(p + 24)
41
+ if offset:
42
+ self.class_data(offset, desc)
43
+
44
+ def require(self, offset, size):
45
+ if offset < 0 or size < 0 or offset + size > len(self.data):
46
+ raise ValueError('DEX table/data out of bounds')
47
+
48
+ def u16(self, offset):
49
+ self.require(offset, 2)
50
+ return struct.unpack_from('<H', self.data, offset)[0]
51
+
52
+ def u32(self, offset):
53
+ self.require(offset, 4)
54
+ return struct.unpack_from('<I', self.data, offset)[0]
55
+
56
+ @staticmethod
57
+ def at(values, index):
58
+ if index < 0 or index >= len(values):
59
+ raise ValueError('DEX reference index out of bounds')
60
+ return values[index]
61
+
62
+ def table(self, header, width):
63
+ size, offset = self.u32(header), self.u32(header + 4)
64
+ if size and not offset:
65
+ raise ValueError('DEX table offset missing')
66
+ self.require(offset, size * width)
67
+ return range(offset, offset + size * width, width)
68
+
69
+ def uleb(self, offset):
70
+ value = 0
71
+ for shift in range(0, 35, 7):
72
+ self.require(offset, 1)
73
+ byte = self.data[offset]
74
+ offset += 1
75
+ if shift == 28 and byte > 15:
76
+ raise ValueError('DEX ULEB128 overflow')
77
+ value |= (byte & 127) << shift
78
+ if not byte & 128:
79
+ return value, offset
80
+ raise ValueError('DEX ULEB128 overflow')
81
+
82
+ def string(self, offset):
83
+ length, pos = self.uleb(offset)
84
+ end = self.data.find(b'\0', pos)
85
+ if end < 0:
86
+ raise ValueError('Unterminated DEX string')
87
+ # DEX MUTF-8 encodes NUL and UTF-16 surrogate pairs specially.
88
+ raw = self.data[pos:end].replace(b'\xc0\x80', b'\0')
89
+ value = raw.decode('utf-8', errors='surrogatepass')
90
+ utf16 = value.encode('utf-16-le', errors='surrogatepass')
91
+ if len(utf16) // 2 != length:
92
+ raise ValueError('DEX string UTF-16 length mismatch')
93
+ return utf16.decode('utf-16-le')
94
+
95
+ def type_list(self, offset):
96
+ if not offset:
97
+ return ()
98
+ size = self.u32(offset)
99
+ self.require(offset + 4, size * 2)
100
+ return tuple(self.at(self.types, self.u16(offset + 4 + 2 * i)) for i in range(size))
101
+
102
+ def class_data(self, pos, owner):
103
+ counts = []
104
+ for _ in range(4):
105
+ count, pos = self.uleb(pos)
106
+ counts.append(count)
107
+ for count in counts[:2]:
108
+ index = 0
109
+ for _ in range(count):
110
+ delta, pos = self.uleb(pos)
111
+ flags, pos = self.uleb(pos)
112
+ index += delta
113
+ if self.at(self.fields, index)[0] != owner:
114
+ raise ValueError('DEX field definition owner mismatch')
115
+ if index in self.defined_fields:
116
+ raise ValueError('Duplicate DEX field definition')
117
+ self.defined_fields.add(index)
118
+ for count in counts[2:]:
119
+ index = 0
120
+ for _ in range(count):
121
+ delta, pos = self.uleb(pos)
122
+ flags, pos = self.uleb(pos)
123
+ offset, pos = self.uleb(pos)
124
+ index += delta
125
+ if self.at(self.methods, index)[0] != owner:
126
+ raise ValueError('DEX method definition owner mismatch')
127
+ if index in self.defined_methods:
128
+ raise ValueError('Duplicate DEX method definition')
129
+ self.defined_methods.add(index)
130
+ if offset:
131
+ size = self.u32(offset + 12)
132
+ self.require(offset, 16 + size * 2)
133
+ self.code[index] = (offset + 16, self.data[offset + 16:offset + 16 + size * 2])
134
+
135
+ def method_descriptor(self, index):
136
+ owner, name, (ret, params) = self.at(self.methods, index)
137
+ return owner + '->' + name + '(' + ''.join(params) + ')' + ret
@@ -0,0 +1,66 @@
1
+ """LIEF ELF identity extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ElfFeature:
11
+ sha256: str
12
+ abi: str
13
+ soname: str | None
14
+ build_id: str | None
15
+ imported_symbols: tuple[str, ...]
16
+ exported_symbols: tuple[str, ...]
17
+ symbol_digest: str | None
18
+
19
+
20
+ def _name(value) -> str | None:
21
+ if value is None:
22
+ return None
23
+ n = getattr(value, "name", None)
24
+ return str(n) if n else None
25
+
26
+
27
+ def parse_elf(data: bytes) -> ElfFeature:
28
+ import lief
29
+
30
+ parsed = lief.ELF.parse(list(data))
31
+ if parsed is None:
32
+ raise ValueError("LIEF returned no ELF object")
33
+ header = getattr(parsed, "header", None)
34
+ machine = str(getattr(header, "machine_type", "unknown")).split(".")[-1].lower()
35
+ cls = str(getattr(header, "identity_class", "")).split(".")[-1].lower()
36
+ abi = ":".join(x for x in (machine, cls) if x)
37
+ soname = None
38
+ try:
39
+ soname = parsed.soname
40
+ except Exception:
41
+ soname = None
42
+ build_id = None
43
+ for note in getattr(parsed, "notes", []) or []:
44
+ desc = getattr(note, "description", None)
45
+ if desc and "build" in str(getattr(note, "type", "")).lower():
46
+ try:
47
+ build_id = bytes(desc).hex()
48
+ except Exception:
49
+ build_id = str(desc)
50
+ break
51
+ imported = tuple(sorted(n for n in (_name(s) for s in getattr(parsed, "imported_symbols", []) or []) if n))
52
+ exported = tuple(sorted(n for n in (_name(s) for s in getattr(parsed, "exported_symbols", []) or []) if n))
53
+ symbol_digest = None
54
+ if imported or exported:
55
+ payload = "\n".join(["I:" + x for x in imported] + ["E:" + x for x in exported]).encode()
56
+ symbol_digest = hashlib.sha256(b"elf-identity-v1\0" + payload).hexdigest()
57
+ return ElfFeature(
58
+ sha256=hashlib.sha256(data).hexdigest(),
59
+ abi=abi or "unknown",
60
+ soname=soname,
61
+ build_id=build_id,
62
+ imported_symbols=imported,
63
+ exported_symbols=exported,
64
+ symbol_digest=symbol_digest,
65
+ )
66
+