Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/unblob/report.py: 73%
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 base64
4import hashlib
5import stat
6import traceback
7from enum import Enum
8from pathlib import Path
9from typing import TYPE_CHECKING, Annotated, Any, TypeVar
11if TYPE_CHECKING:
12 from collections.abc import Iterable
14from pydantic import (
15 BaseModel,
16 BeforeValidator,
17 ConfigDict,
18 Field,
19 computed_field,
20 field_serializer,
21 field_validator,
22)
25def ensure_bytes(value: Any) -> bytes:
26 if isinstance(value, bytes):
27 return value
28 if isinstance(value, str):
29 return value.encode()
30 if value is None:
31 return b""
32 raise ValueError(f"Unsupported type in ensure_bytes: {type(value)}")
35class Report(BaseModel):
36 """A common base class for different reports. This will enable easy pydantic configuration of all models from a single point in the future if desired."""
38 @computed_field
39 @property
40 def __typename__(self) -> str:
41 return self.__class__.__name__
44class Severity(Enum):
45 """Represents possible problems encountered during execution."""
47 ERROR = "ERROR"
48 WARNING = "WARNING"
51class ErrorReport(Report):
52 severity: Severity
55class UnknownError(ErrorReport):
56 """Describes an exception raised during file processing."""
58 severity: Severity = Severity.ERROR
59 exception: str | Exception
61 model_config = ConfigDict(
62 arbitrary_types_allowed=True
63 ) # Necessary to support Exception type
65 def model_post_init(self, _: Any) -> None:
66 if isinstance(self.exception, Exception):
67 self.exception = "".join(
68 traceback.format_exception(
69 type(self.exception), self.exception, self.exception.__traceback__
70 )
71 )
73 """Exceptions are also formatted at construct time."""
76class CalculateChunkExceptionReport(UnknownError):
77 """Describes an exception raised during calculate_chunk execution."""
79 start_offset: int
80 # Stored in `str` rather than `Handler`, because the pickle picks ups structs from `C_DEFINITIONS`
81 handler: str
84class CalculateMultiFileExceptionReport(UnknownError):
85 """Describes an exception raised during calculate_chunk execution."""
87 path: Path
88 # Stored in `str` rather than `Handler`, because the pickle picks ups structs from `C_DEFINITIONS`
89 handler: str
92class ExtractCommandFailedReport(ErrorReport):
93 """Describes an error when failed to run the extraction command."""
95 severity: Severity = Severity.WARNING
96 command: str
97 stdout: Annotated[bytes, BeforeValidator(ensure_bytes)]
98 stderr: Annotated[bytes, BeforeValidator(ensure_bytes)]
99 exit_code: int
101 # Use base64 to encode and decode bytes data in case there are non-standard characters
102 @field_serializer("stdout", "stderr")
103 def encode_bytes(self, v: bytes, _):
104 return base64.b64encode(v).decode("ascii")
106 @field_validator("stdout", "stderr", mode="before")
107 @classmethod
108 def decode_bytes(cls, v: Any):
109 if isinstance(v, str):
110 return base64.b64decode(v)
111 return v
114class OutputDirectoryExistsReport(ErrorReport):
115 severity: Severity = Severity.ERROR
116 path: Path
119class ExtractorDependencyNotFoundReport(ErrorReport):
120 """Describes an error when the dependency of an extractor doesn't exist."""
122 severity: Severity = Severity.ERROR
123 dependencies: list[str]
126class ExtractorTimedOut(ErrorReport):
127 """Describes an error when the extractor execution timed out."""
129 severity: Severity = Severity.ERROR
130 cmd: str
131 timeout: float
134class MaliciousSymlinkRemoved(ErrorReport):
135 """Describes an error when malicious symlinks have been removed from disk."""
137 severity: Severity = Severity.WARNING
138 link: str
139 target: str
142class MultiFileCollisionReport(ErrorReport):
143 """Describes an error when MultiFiles collide on the same file."""
145 severity: Severity = Severity.ERROR
146 paths: set[Path]
147 handler: str
150class StatReport(Report):
151 path: Path
152 size: int
153 is_dir: bool
154 is_file: bool
155 is_link: bool
156 link_target: Path | None
158 @classmethod
159 def from_path(cls, path: Path):
160 st = path.lstat()
161 mode = st.st_mode
162 try:
163 link_target = Path.readlink(path)
164 except OSError:
165 link_target = None
167 return cls(
168 path=path,
169 size=st.st_size,
170 is_dir=stat.S_ISDIR(mode),
171 is_file=stat.S_ISREG(mode),
172 is_link=stat.S_ISLNK(mode),
173 link_target=link_target,
174 )
177class HashReport(Report):
178 md5: str
179 sha1: str
180 sha256: str
182 @classmethod
183 def from_path(cls, path: Path):
184 chunk_size = 1024 * 64
185 md5 = hashlib.md5(usedforsecurity=False)
186 sha1 = hashlib.sha1(usedforsecurity=False)
187 sha256 = hashlib.sha256()
189 with path.open("rb") as f:
190 while chunk := f.read(chunk_size):
191 md5.update(chunk)
192 sha1.update(chunk)
193 sha256.update(chunk)
195 return cls(
196 md5=md5.hexdigest(),
197 sha1=sha1.hexdigest(),
198 sha256=sha256.hexdigest(),
199 )
202class FileMagicReport(Report):
203 magic: str
204 mime_type: str
207class RandomnessMeasurements(BaseModel):
208 percentages: list[float]
209 block_size: int
210 mean: float
212 @property
213 def highest(self):
214 return max(self.percentages)
216 @property
217 def lowest(self):
218 return min(self.percentages)
221class RandomnessReport(Report):
222 shannon: RandomnessMeasurements
223 chi_square: RandomnessMeasurements
226class MetadataReport(Report):
227 pass
230class EncryptionMetadataReport(MetadataReport):
231 is_encrypted: bool
234class HandledBlobReport(Report):
235 id: str
236 handler_name: str
237 extraction_reports: list[Report]
238 metadata_reports: list[MetadataReport] = Field(default_factory=list)
240 @field_validator("extraction_reports", "metadata_reports", mode="before")
241 @classmethod
242 def validate_reports(cls, value: Any) -> list[Report]:
243 return validate_report_list(value)
246class ChunkReport(HandledBlobReport):
247 start_offset: int
248 end_offset: int
249 size: int
252class UnknownChunkReport(Report):
253 id: str
254 start_offset: int
255 end_offset: int
256 size: int
257 randomness: RandomnessReport | None
259 @field_validator("randomness", mode="before")
260 @classmethod
261 def validate_randomness(cls, value: Any) -> RandomnessReport | None:
262 if value is None:
263 return None
264 parsed = parse_report(value)
265 if not isinstance(parsed, RandomnessReport):
266 raise TypeError("Randomness must be a RandomnessReport.")
267 return parsed
270class CarveDirectoryReport(Report):
271 carve_dir: Path
274class MultiFileReport(HandledBlobReport):
275 name: str
276 paths: list[Path]
279class ExtractedFileDeletedReport(Report):
280 path: Path
281 handler_name: str
284class ExtractionProblem(Report):
285 """A non-fatal problem discovered during extraction.
287 A report like this still means, that the extraction was successful,
288 but there were problems that got resolved.
289 The output is expected to be complete, with the exception of
290 the reported path.
292 Examples
293 --------
294 - duplicate entries for certain archive formats (tar, zip)
295 - unsafe symlinks pointing outside of extraction directory
297 """
299 problem: str
300 resolution: str
301 path: str | None = None
303 @property
304 def log_msg(self):
305 return f"{self.problem} {self.resolution}"
307 def log_with(self, logger):
308 logger.warning(self.log_msg, path=self.path)
311class PathTraversalProblem(ExtractionProblem):
312 extraction_path: str
314 def log_with(self, logger):
315 logger.warning(
316 self.log_msg,
317 path=self.path,
318 extraction_path=self.extraction_path,
319 )
322class LinkExtractionProblem(ExtractionProblem):
323 link_path: str
325 def log_with(self, logger):
326 logger.warning(self.log_msg, path=self.path, link_path=self.link_path)
329class SpecialFileExtractionProblem(ExtractionProblem):
330 mode: int
331 device: int
333 def log_with(self, logger):
334 logger.warning(self.log_msg, path=self.path, mode=self.mode, device=self.device)
337class ExtendedAttributeExtractionProblem(ExtractionProblem):
338 attribute: str
340 def log_with(self, logger):
341 logger.warning(self.log_msg, path=self.path, attribute=self.attribute)
344BUILTIN_REPORT_TYPES: tuple[type[Report], ...] = (
345 ErrorReport,
346 UnknownError,
347 CalculateChunkExceptionReport,
348 CalculateMultiFileExceptionReport,
349 ExtractCommandFailedReport,
350 OutputDirectoryExistsReport,
351 ExtractorDependencyNotFoundReport,
352 ExtractedFileDeletedReport,
353 ExtractorTimedOut,
354 MaliciousSymlinkRemoved,
355 MultiFileCollisionReport,
356 StatReport,
357 HashReport,
358 FileMagicReport,
359 RandomnessReport,
360 EncryptionMetadataReport,
361 ChunkReport,
362 UnknownChunkReport,
363 CarveDirectoryReport,
364 MultiFileReport,
365 ExtractionProblem,
366 PathTraversalProblem,
367 LinkExtractionProblem,
368 SpecialFileExtractionProblem,
369)
371_REPORT_REGISTRY: dict[str, type[Report]] = {}
372ReportType = TypeVar("ReportType", bound=type[Report])
375def register_report_type(report_type: ReportType) -> ReportType:
376 typename = report_type.__name__
377 existing = _REPORT_REGISTRY.get(typename)
378 if existing is not None and existing is not report_type:
379 raise ValueError(f"Report type name conflict: {typename}")
380 _REPORT_REGISTRY[typename] = report_type
381 return report_type
384def register_report_types(report_types: Iterable[type[Report]]) -> None:
385 for report_type in report_types:
386 register_report_type(report_type)
389def get_report_type(typename: str) -> type[Report] | None:
390 return _REPORT_REGISTRY.get(typename)
393def parse_report(report: Report | dict[str, Any]) -> Report:
394 if isinstance(report, Report):
395 return report
396 if not isinstance(report, dict):
397 raise TypeError("Report data must be a mapping or Report instance.")
398 typename = report.get("__typename__")
399 if not typename:
400 raise ValueError("Report data is missing __typename__.")
401 report_type = get_report_type(typename)
402 if report_type is None:
403 raise ValueError(f"Unknown report type: {typename}")
404 return report_type.model_validate(report)
407def validate_report_list(value: Any) -> list[Report]:
408 if not isinstance(value, list):
409 raise TypeError("Report list must be a list.")
410 return [parse_report(item) for item in value]
413register_report_types(BUILTIN_REPORT_TYPES)