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.
- libidentifier/__init__.py +36 -0
- libidentifier/api.py +90 -0
- libidentifier/archive.py +133 -0
- libidentifier/cli.py +340 -0
- libidentifier/cli_resources.py +36 -0
- libidentifier/cli_setup.py +177 -0
- libidentifier/config.py +54 -0
- libidentifier/detect.py +30 -0
- libidentifier/errors.py +30 -0
- libidentifier/extractors/__init__.py +2 -0
- libidentifier/extractors/dex.py +121 -0
- libidentifier/extractors/dex_tables.py +137 -0
- libidentifier/extractors/elf.py +66 -0
- libidentifier/extractors/instructions.py +77 -0
- libidentifier/extractors/metadata.py +99 -0
- libidentifier/extractors/version_metadata.py +72 -0
- libidentifier/fingerprint/__init__.py +2 -0
- libidentifier/fingerprint/dex_v2.py +97 -0
- libidentifier/fingerprint/elf_v1.py +16 -0
- libidentifier/grouping.py +74 -0
- libidentifier/inventory.py +260 -0
- libidentifier/matcher.py +381 -0
- libidentifier/models.py +423 -0
- libidentifier/profiles/__init__.py +2 -0
- libidentifier/profiles/builder.py +182 -0
- libidentifier/profiles/catalog.py +135 -0
- libidentifier/profiles/distribution.py +303 -0
- libidentifier/profiles/fetcher.py +141 -0
- libidentifier/profiles/pom.py +66 -0
- libidentifier/profiles/schema.py +137 -0
- libidentifier/profiles/store.py +212 -0
- libidentifier/purl.py +36 -0
- libidentifier/relationships.py +168 -0
- libidentifier/sbom.py +62 -0
- libidentifier/serialization.py +53 -0
- libidentifier/table.py +254 -0
- libidentifier/version.py +2 -0
- libidentifier/vulnerabilities/__init__.py +2 -0
- libidentifier/vulnerabilities/correlate.py +164 -0
- libidentifier/vulnerabilities/database.py +213 -0
- libidentifier/vulnerabilities/models.py +155 -0
- libidentifier/vulnerabilities/runner.py +148 -0
- libidentifier/vulnerabilities/setup.py +74 -0
- libidentifier-0.2.0rc1.dist-info/METADATA +156 -0
- libidentifier-0.2.0rc1.dist-info/RECORD +50 -0
- libidentifier-0.2.0rc1.dist-info/WHEEL +5 -0
- libidentifier-0.2.0rc1.dist-info/entry_points.txt +2 -0
- libidentifier-0.2.0rc1.dist-info/licenses/LICENSE +62 -0
- libidentifier-0.2.0rc1.dist-info/licenses/THIRD_PARTY_NOTICES.md +15 -0
- 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
|
libidentifier/config.py
ADDED
|
@@ -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
|
libidentifier/detect.py
ADDED
|
@@ -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
|
+
|
libidentifier/errors.py
ADDED
|
@@ -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,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
|
+
|