Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/unblob/models.py: 75%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from __future__ import annotations
3import abc
4import dataclasses
5import itertools
6import json
7from enum import Enum
8from pathlib import Path # noqa: TC003
9from typing import TYPE_CHECKING, Generic, TypeVar
11import attrs
12from pydantic import BaseModel, TypeAdapter, field_validator
13from structlog import get_logger
15from .file_utils import Endian, File, InvalidInputFormat, StructParser
16from .identifiers import new_id
17from .parser import hexstring2regex
18from .report import (
19 CarveDirectoryReport,
20 ChunkReport,
21 EncryptionMetadataReport,
22 ErrorReport,
23 MetadataReport,
24 MultiFileReport,
25 RandomnessReport,
26 Report,
27 UnknownChunkReport,
28 validate_report_list,
29)
31if TYPE_CHECKING:
32 from collections.abc import Iterable
34__all__ = [
35 "Blob",
36 "Chunk",
37 "DExtractor",
38 "DirectoryExtractor",
39 "DirectoryHandler",
40 "DirectoryHandlers",
41 "DirectoryPattern",
42 "Endian",
43 "ExtractError",
44 "ExtractResult",
45 "Extractor",
46 "File",
47 "Glob",
48 "Handler",
49 "HandlerDoc",
50 "HandlerType",
51 "Handlers",
52 "HexString",
53 "InvalidInputFormat",
54 "MultiFile",
55 "PaddingChunk",
56 "Pattern",
57 "ProcessResult",
58 "Reference",
59 "Regex",
60 "ReportModel",
61 "ReportModelAdapter",
62 "SingleFile",
63 "StructHandler",
64 "StructParser",
65 "TExtractor",
66 "Task",
67 "TaskResult",
68 "UnknownChunk",
69 "ValidChunk",
70]
72logger = get_logger()
74# The state transitions are:
75#
76# file ──► pattern match ──► ValidChunk
77#
80class HandlerType(Enum):
81 ARCHIVE = "Archive"
82 COMPRESSION = "Compression"
83 FILESYSTEM = "FileSystem"
84 EXECUTABLE = "Executable"
85 BAREMETAL = "Baremetal"
86 BOOTLOADER = "Bootloader"
87 ENCRYPTION = "Encryption"
90@dataclasses.dataclass(frozen=True)
91class Reference:
92 title: str
93 url: str
96@dataclasses.dataclass
97class HandlerDoc:
98 name: str
99 description: str | None
100 vendor: str | None
101 references: list[Reference]
102 limitations: list[str]
103 handler_type: HandlerType
104 private: bool = False
105 fully_supported: bool = dataclasses.field(init=False)
107 def __post_init__(self):
108 self.fully_supported = len(self.limitations) == 0
111class Task(BaseModel):
112 path: Path
113 depth: int
114 blob_id: str
115 is_multi_file: bool = False
118@attrs.define
119class Blob:
120 id: str = attrs.field(
121 factory=new_id,
122 )
125@attrs.define
126class Chunk(Blob):
127 """File chunk, have start and end offset, but still can be invalid.
129 For an array ``b``, a chunk ``c`` represents the slice:
130 ::
132 b[c.start_offset:c.end_offset]
133 """
135 start_offset: int = attrs.field(kw_only=True)
136 """The index of the first byte of the chunk"""
138 end_offset: int = attrs.field(kw_only=True)
139 """The index of the first byte after the end of the chunk"""
141 file: File | None = None
143 def __attrs_post_init__(self):
144 if self.start_offset < 0 or self.end_offset < 0:
145 raise InvalidInputFormat(f"Chunk has negative offset: {self}")
146 if self.start_offset >= self.end_offset:
147 raise InvalidInputFormat(
148 f"Chunk has higher start_offset than end_offset: {self}"
149 )
151 @property
152 def size(self) -> int:
153 return self.end_offset - self.start_offset
155 @property
156 def range_hex(self) -> str:
157 return f"0x{self.start_offset:x}-0x{self.end_offset:x}"
159 @property
160 def is_whole_file(self):
161 assert self.file
162 return self.start_offset == 0 and self.end_offset == self.file.size()
164 def contains(self, other: Chunk) -> bool:
165 return (
166 self.start_offset < other.start_offset
167 and self.end_offset >= other.end_offset
168 ) or (
169 self.start_offset <= other.start_offset
170 and self.end_offset > other.end_offset
171 )
173 def contains_offset(self, offset: int) -> bool:
174 return self.start_offset <= offset < self.end_offset
176 def __repr__(self) -> str:
177 return self.range_hex
180@attrs.define(repr=False)
181class ValidChunk(Chunk):
182 """Known to be valid chunk of a File, can be extracted with an external program."""
184 handler: Handler = attrs.field(init=False, eq=False)
185 metadata_reports: list[MetadataReport] = attrs.field(factory=list, kw_only=True)
187 @property
188 def is_encrypted(self) -> bool:
189 return any(
190 report.is_encrypted
191 for report in self.metadata_reports
192 if isinstance(report, EncryptionMetadataReport)
193 )
195 def extract(self, inpath: Path, outdir: Path) -> ExtractResult | None:
196 if self.is_encrypted:
197 logger.warning(
198 "Encrypted file is not extracted",
199 path=inpath,
200 chunk=self,
201 )
202 raise ExtractError
204 return self.handler.extract(inpath, outdir)
206 def as_report(self, extraction_reports: list[Report]) -> ChunkReport:
207 return ChunkReport(
208 id=self.id,
209 start_offset=self.start_offset,
210 end_offset=self.end_offset,
211 size=self.size,
212 handler_name=self.handler.NAME,
213 metadata_reports=self.metadata_reports,
214 extraction_reports=extraction_reports,
215 )
218@attrs.define(repr=False)
219class UnknownChunk(Chunk):
220 r"""Gaps between valid chunks or otherwise unknown chunks.
222 Important for manual analysis, and analytical certainty: for example
223 randomness, other chunks inside it, metadata, etc.
225 These are not extracted, just logged for information purposes and further analysis,
226 like most common bytes (like \x00 and \xFF), ASCII strings, high randomness, etc.
227 """
229 def as_report(self, randomness: RandomnessReport | None) -> UnknownChunkReport:
230 return UnknownChunkReport(
231 id=self.id,
232 start_offset=self.start_offset,
233 end_offset=self.end_offset,
234 size=self.size,
235 randomness=randomness,
236 )
239@attrs.define(repr=False)
240class PaddingChunk(Chunk):
241 r"""Gaps between valid chunks or otherwise unknown chunks.
243 Important for manual analysis, and analytical certanity: for example
244 randomness, other chunks inside it, metadata, etc.
245 """
247 def as_report(
248 self,
249 randomness: RandomnessReport | None, # noqa: ARG002
250 ) -> ChunkReport:
251 return ChunkReport(
252 id=self.id,
253 start_offset=self.start_offset,
254 end_offset=self.end_offset,
255 size=self.size,
256 handler_name="padding",
257 extraction_reports=[],
258 metadata_reports=[],
259 )
262@attrs.define
263class MultiFile(Blob):
264 name: str = attrs.field(kw_only=True)
265 paths: list[Path] = attrs.field(kw_only=True)
267 handler: DirectoryHandler = attrs.field(init=False, eq=False)
269 def extract(self, outdir: Path) -> ExtractResult | None:
270 return self.handler.extract(self.paths, outdir)
272 def as_report(self, extraction_reports: list[Report]) -> MultiFileReport:
273 return MultiFileReport(
274 id=self.id,
275 name=self.name,
276 paths=self.paths,
277 handler_name=self.handler.NAME,
278 extraction_reports=extraction_reports,
279 metadata_reports=[],
280 )
283ReportType = TypeVar("ReportType", bound=Report)
286class TaskResult(BaseModel):
287 task: Task
288 reports: list[Report] = []
289 subtasks: list[Task] = []
291 @field_validator("reports", mode="before")
292 @classmethod
293 def validate_reports(cls, value):
294 return validate_report_list(value)
296 def add_report(self, report: Report):
297 self.reports.append(report)
299 def add_subtask(self, task: Task):
300 self.subtasks.append(task)
302 def filter_reports(self, report_class: type[ReportType]) -> list[ReportType]:
303 return [report for report in self.reports if isinstance(report, report_class)]
306class ProcessResult(BaseModel):
307 results: list[TaskResult] = []
309 @property
310 def errors(self) -> list[ErrorReport]:
311 reports = itertools.chain.from_iterable(r.reports for r in self.results)
312 interesting_reports = (
313 r for r in reports if isinstance(r, ErrorReport | ChunkReport)
314 )
315 errors = []
316 for report in interesting_reports:
317 if isinstance(report, ErrorReport):
318 errors.append(report)
319 else:
320 errors.extend(
321 r for r in report.extraction_reports if isinstance(r, ErrorReport)
322 )
323 return errors
325 def register(self, result: TaskResult):
326 self.results.append(result)
328 def to_json(self, indent=" "):
329 return json.dumps(
330 [
331 result.model_dump(mode="json", serialize_as_any=True)
332 for result in self.results
333 ],
334 indent=indent,
335 )
337 def get_output_dir(self) -> Path | None:
338 try:
339 top_result = self.results[0]
340 if carves := top_result.filter_reports(CarveDirectoryReport):
341 # we have a top level carve
342 return carves[0].carve_dir
344 # we either have an extraction,
345 # and the extract directory registered as subtask
346 return top_result.subtasks[0].path
347 except IndexError:
348 # or no extraction
349 return None
352ReportModel = list[TaskResult]
353ReportModelAdapter = TypeAdapter(ReportModel)
354"""Use this for deserialization (import JSON report back into Python
355objects) of the JSON report.
357For example:
359with open('report.json', 'r') as f:
360 data = f.read()
361 report_data = ReportModelAdapter.validate_json(data)
363For another example see:
364tests/test_models.py::Test_to_json::test_process_result_deserialization
365"""
368class ExtractError(Exception):
369 """There was an error during extraction."""
371 def __init__(self, *reports: Report):
372 super().__init__()
373 self.reports: tuple[Report, ...] = reports
376@attrs.define(kw_only=True)
377class ExtractResult:
378 reports: list[Report]
381class Extractor(abc.ABC):
382 def get_dependencies(self) -> list[str]:
383 """Return the external command dependencies."""
384 return []
386 @abc.abstractmethod
387 def extract(self, inpath: Path, outdir: Path) -> ExtractResult | None:
388 """Extract the carved out chunk.
390 Raises ExtractError on failure.
391 """
394class DirectoryExtractor(abc.ABC):
395 def get_dependencies(self) -> list[str]:
396 """Return the external command dependencies."""
397 return []
399 @abc.abstractmethod
400 def extract(self, paths: list[Path], outdir: Path) -> ExtractResult | None:
401 """Extract from a multi file path list.
403 Raises ExtractError on failure.
404 """
407class Pattern(str):
408 def as_regex(self) -> bytes:
409 raise NotImplementedError
412class HexString(Pattern):
413 """Hex string can be a YARA rule like hexadecimal string.
415 It is useful to simplify defining binary strings using hex
416 encoding, wild-cards, jumps and alternatives. Hexstrings are
417 convereted to hyperscan compatible PCRE regex.
419 See YARA & Hyperscan documentation for more details:
421 - https://yara.readthedocs.io/en/stable/writingrules.html#hexadecimal-strings
423 - https://intel.github.io/hyperscan/dev-reference/compilation.html#pattern-support
425 You can specify the following:
427 - normal bytes using hexadecimals: 01 de ad co de ff
429 - wild-cards can match single bytes and can be mixed with
430 normal hex: 01 ?? 02
432 - wild-cards can also match first and second nibles: 0? ?0
434 - jumps can be specified for multiple wildcard bytes: [3]
435 [2-5]
437 - alternatives can be specified as well: ( 01 02 | 03 04 ) The
438 above can be combined and alternatives nested: 01 02 ( 03 04
439 | (0? | 03 | ?0) | 05 ?? ) 06
441 Single line comments can be specified using //
443 We do NOT support the following YARA syntax:
445 - comments using /* */ notation
447 - infinite jumps: [-]
449 - unbounded jumps: [3-] or [-4] (use [0-4] instead)
450 """
452 def as_regex(self) -> bytes:
453 return hexstring2regex(self)
456class Regex(Pattern):
457 """Byte PCRE regex.
459 See hyperscan documentation for more details:
460 https://intel.github.io/hyperscan/dev-reference/compilation.html#pattern-support.
461 """
463 def as_regex(self) -> bytes:
464 return self.encode()
467class DirectoryPattern:
468 def get_files(self, directory: Path) -> Iterable[Path]:
469 raise NotImplementedError
472class Glob(DirectoryPattern):
473 def __init__(self, *patterns):
474 if not patterns:
475 raise ValueError("At least one pattern must be provided")
476 self._patterns = patterns
478 def get_files(self, directory: Path) -> Iterable[Path]:
479 for pattern in self._patterns:
480 yield from directory.glob(pattern)
483class SingleFile(DirectoryPattern):
484 def __init__(self, filename):
485 self._filename = filename
487 def get_files(self, directory: Path) -> Iterable[Path]:
488 path = directory / self._filename
489 return [path] if path.exists() else []
492DExtractor = TypeVar("DExtractor", bound=DirectoryExtractor | None)
495class DirectoryHandler(abc.ABC, Generic[DExtractor]):
496 """A directory type handler is responsible for searching, validating and "unblobbing" files from multiple files in a directory."""
498 NAME: str
500 EXTRACTOR: DExtractor
502 PATTERN: DirectoryPattern
504 DOC: HandlerDoc | None
506 @classmethod
507 def get_dependencies(cls):
508 """Return external command dependencies needed for this handler to work."""
509 if cls.EXTRACTOR is not None:
510 return cls.EXTRACTOR.get_dependencies()
511 return []
513 @abc.abstractmethod
514 def calculate_multifile(self, file: Path) -> MultiFile | None:
515 """Calculate the MultiFile in a directory, using a file matched by the pattern as a starting point."""
517 def extract(self, paths: list[Path], outdir: Path) -> ExtractResult | None:
518 if self.EXTRACTOR is None:
519 logger.debug("Skipping file: no extractor.", paths=paths)
520 raise ExtractError
522 # We only extract every blob once, it's a mistake to extract the same blob again
523 outdir.mkdir(parents=True, exist_ok=False)
525 return self.EXTRACTOR.extract(paths, outdir)
528TExtractor = TypeVar("TExtractor", bound=Extractor | None)
531class Handler(abc.ABC, Generic[TExtractor]):
532 """A file type handler is responsible for searching, validating and "unblobbing" files from Blobs."""
534 NAME: str
535 PATTERNS: list[Pattern]
536 # We need this, because not every match reflects the actual start
537 # (e.g. tar magic is in the middle of the header)
538 PATTERN_MATCH_OFFSET: int = 0
540 EXTRACTOR: TExtractor
542 DOC: HandlerDoc | None
544 @classmethod
545 def get_dependencies(cls):
546 """Return external command dependencies needed for this handler to work."""
547 if cls.EXTRACTOR is not None:
548 return cls.EXTRACTOR.get_dependencies()
549 return []
551 @abc.abstractmethod
552 def calculate_chunk(self, file: File, start_offset: int) -> ValidChunk | None:
553 """Calculate the Chunk offsets from the File and the file type headers."""
555 def extract(self, inpath: Path, outdir: Path) -> ExtractResult | None:
556 if self.EXTRACTOR is None:
557 logger.debug("Skipping file: no extractor.", path=inpath)
558 raise ExtractError
560 # We only extract every blob once, it's a mistake to extract the same blob again
561 outdir.mkdir(parents=True, exist_ok=False)
563 return self.EXTRACTOR.extract(inpath, outdir)
566class StructHandler(Handler):
567 C_DEFINITIONS: str
568 # A struct from the C_DEFINITIONS used to parse the file's header
569 HEADER_STRUCT: str
571 def __init__(self):
572 self._struct_parser = StructParser(self.C_DEFINITIONS)
574 @property
575 def cparser_le(self):
576 return self._struct_parser.cparser_le
578 @property
579 def cparser_be(self):
580 return self._struct_parser.cparser_be
582 def parse_header(self, file: File, endian=Endian.LITTLE):
583 header = self._struct_parser.parse(self.HEADER_STRUCT, file, endian)
584 logger.debug("Header parsed", header=header, _verbosity=3)
585 return header
588Handlers = tuple[type[Handler], ...]
589DirectoryHandlers = tuple[type[DirectoryHandler], ...]