mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-28 16:56:23 +01:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
895307e242 | ||
|
|
e5a9a1f919 | ||
|
|
c5d7f5a1d0 |
No files matched your search
@@ -62,6 +62,27 @@ AndroidQF will prompt the user to download, decrypt and collect device intrusion
|
||||
|
||||
For cases where intrusion logs were collected outside of an AndroidQF acquisition, the standalone `mvt-android check-intrusion-logs` command can analyse them directly. See [Check Android Intrusion Logs](intrusion_logs.md) for details, and the [feature announcment from Amnesty International's Security Lab](https://securitylab.amnesty.org/latest/2026/05/android-intrusion-logging-as-a-new-source-of-data-for-consensual-forensic-analysis/) for background on the data source.
|
||||
|
||||
## Browser history
|
||||
|
||||
AndroidQF can optionally collect browser `History` databases when a device
|
||||
already has working root access. When its acquisition contains the
|
||||
`browser_history/manifest.json` file, `mvt-android check-androidqf` parses every
|
||||
listed database, including any collected SQLite WAL and SHM sidecars. Browser
|
||||
visits are added to the module results, timeline, URL output, and indicator
|
||||
checks.
|
||||
|
||||
MVT can also inspect the supported database locations in a full Android
|
||||
filesystem dump, or inspect one explicitly supplied Chromium `History` file:
|
||||
|
||||
```bash
|
||||
mvt-android check-fs --output /path/to/results /path/to/filesystem-dump
|
||||
mvt-android check-fs --output /path/to/results /path/to/History
|
||||
```
|
||||
|
||||
The built-in locations cover Chrome, Brave, Microsoft Edge, and Samsung
|
||||
Internet. Other Chromium-compatible databases can be supplied explicitly as a
|
||||
file without assigning an unverified browser identity.
|
||||
|
||||
## Android Debug Bridge analysis removed
|
||||
|
||||
The ability to analyze Android devices directly over ADB has been removed from MVT. Direct extraction of data from ADB was error-prone and frequently resulted in inconsistent data collection between ADB and AndroidQF acquisitions. Use AndroidQF for device acquisition and `mvt-android check-androidqf` for analysis.
|
||||
|
||||
@@ -389,9 +389,3 @@ MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
||||
```
|
||||
|
||||
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
|
||||
|
||||
## Output schemas
|
||||
|
||||
Custom modules should declare an `output_model` using a Pydantic `RootModel`.
|
||||
This validates saved results and lets consumers obtain the module's JSON Schema.
|
||||
See [Output schemas](../output-schemas.md#custom-modules) for a complete example.
|
||||
@@ -1,77 +0,0 @@
|
||||
# Output schemas
|
||||
|
||||
MVT validates its JSON output with versioned Pydantic models. The schema version
|
||||
used for a run is recorded as `output_schema_version` in `info.json`.
|
||||
|
||||
MVT preserves the established on-disk format: most module files contain an array
|
||||
of result objects, while modules whose results are naturally grouped contain an
|
||||
object keyed by source or namespace. Detection files and `alerts.json` contain
|
||||
arrays of alert objects. Timestamps remain strings because their timezone and
|
||||
precision depend on the source artifact.
|
||||
|
||||
## Exporting JSON Schema
|
||||
|
||||
Both platform commands can print a versioned JSON Schema bundle:
|
||||
|
||||
```bash
|
||||
mvt-ios schemas
|
||||
mvt-android schemas
|
||||
```
|
||||
|
||||
Use `--output` to write one Draft 2020-12 JSON Schema file for each output:
|
||||
|
||||
```bash
|
||||
mvt-ios schemas --output ./mvt-ios-schemas
|
||||
mvt-android schemas --output ./mvt-android-schemas
|
||||
```
|
||||
|
||||
## Python API
|
||||
|
||||
Models and schema discovery functions are available from `mvt.schemas`:
|
||||
|
||||
```python
|
||||
from mvt.schemas import get_output_model
|
||||
|
||||
SafariHistoryOutput = get_output_model("safari_history", platform="ios")
|
||||
validated = SafariHistoryOutput.model_validate(records)
|
||||
json_schema = SafariHistoryOutput.model_json_schema()
|
||||
```
|
||||
|
||||
Common outputs have dedicated field-level models. Built-in module outputs have a
|
||||
declared root shape, and modules with an established dedicated record model expose
|
||||
its complete field schema.
|
||||
|
||||
## Custom modules
|
||||
|
||||
Custom modules can publish a precise contract by assigning a Pydantic root model
|
||||
to `output_model`:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, RootModel
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
class ExampleRecord(BaseModel):
|
||||
message: str
|
||||
timestamp: str | None = None
|
||||
|
||||
|
||||
class ExampleOutput(RootModel[list[ExampleRecord]]):
|
||||
pass
|
||||
|
||||
|
||||
class ExampleModule(MVTModule):
|
||||
output_model = ExampleOutput
|
||||
```
|
||||
|
||||
For compatibility, a custom module without `output_model` can still write an
|
||||
array or object containing JSON values. MVT logs a warning when it uses this
|
||||
generic contract. A future major release may require custom modules to declare
|
||||
their output models.
|
||||
|
||||
## Compatibility policy
|
||||
|
||||
Within one output schema major version, required fields are not removed and field
|
||||
types are not narrowed. New optional fields may be added. A breaking output
|
||||
change requires a new schema major version and a migration note.
|
||||
@@ -32,7 +32,6 @@ nav:
|
||||
- Introduction: "introduction.md"
|
||||
- Installation: "install.md"
|
||||
- Command Completion: "command_completion.md"
|
||||
- Output Schemas: "output-schemas.md"
|
||||
- Using Docker: "docker.md"
|
||||
- MVT for iOS:
|
||||
- iOS Forensic Methodology: "ios/methodology.md"
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import sqlite3
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from mvt.common.utils import convert_chrometime_to_datetime, convert_datetime_to_iso
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
class BrowserHistoryArtifact(AndroidArtifact):
|
||||
"""Shared Chromium History database parsing and result handling."""
|
||||
|
||||
def _parse_browser_history(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
browser: str,
|
||||
package: str,
|
||||
profile: str,
|
||||
source_path: str,
|
||||
) -> None:
|
||||
cursor = connection.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
urls.id,
|
||||
urls.url,
|
||||
urls.title,
|
||||
urls.visit_count,
|
||||
urls.typed_count,
|
||||
visits.id,
|
||||
visits.visit_time,
|
||||
visits.from_visit,
|
||||
visits.transition
|
||||
FROM urls
|
||||
JOIN visits ON visits.url = urls.id
|
||||
ORDER BY visits.visit_time;
|
||||
"""
|
||||
)
|
||||
for row in cursor:
|
||||
timestamp = int(row[6])
|
||||
self.results.append(
|
||||
{
|
||||
"id": row[0],
|
||||
"url": row[1],
|
||||
"title": row[2],
|
||||
"visit_count": row[3],
|
||||
"typed_count": row[4],
|
||||
"visit_id": row[5],
|
||||
"timestamp": timestamp,
|
||||
"isodate": convert_datetime_to_iso(
|
||||
convert_chrometime_to_datetime(timestamp)
|
||||
),
|
||||
"redirect_source": row[7],
|
||||
"transition": row[8],
|
||||
"browser": browser,
|
||||
"package": package,
|
||||
"profile": profile,
|
||||
"source_path": source_path,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
return {
|
||||
"timestamp": record["isodate"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "browser_history",
|
||||
"data": (
|
||||
f"{record['browser']} visit to {record['url']} "
|
||||
f"(visit ID: {record['visit_id']}, profile: {record['profile']})"
|
||||
),
|
||||
}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result, match in zip(
|
||||
self.results,
|
||||
self.indicators.check_url_batches(
|
||||
[[result["url"]] for result in self.results]
|
||||
),
|
||||
):
|
||||
if match:
|
||||
self.alertstore.critical(
|
||||
match.message, "", result, matched_indicator=match.ioc
|
||||
)
|
||||
|
||||
def collect_url_results(self) -> None:
|
||||
for result in self.results:
|
||||
self.add_url_result(result["url"], result.get("isodate"), "browser_history")
|
||||
|
||||
|
||||
class TemporarySQLiteConnection(sqlite3.Connection):
|
||||
temporary_directory: tempfile.TemporaryDirectory | None = None
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
super().close()
|
||||
finally:
|
||||
if self.temporary_directory:
|
||||
self.temporary_directory.cleanup()
|
||||
self.temporary_directory = None
|
||||
|
||||
|
||||
def open_browser_history_database(database_path: Path) -> sqlite3.Connection:
|
||||
"""Open a staged History database without modifying forensic evidence."""
|
||||
database_uri = database_path.resolve().as_uri()
|
||||
if not Path(f"{database_path}-wal").is_file():
|
||||
return sqlite3.connect(f"{database_uri}?mode=ro&immutable=1", uri=True)
|
||||
|
||||
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_")
|
||||
temporary_path = Path(temporary_directory.name) / database_path.name
|
||||
shutil.copy2(database_path, temporary_path)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = Path(f"{database_path}{suffix}")
|
||||
if sidecar.is_file():
|
||||
shutil.copy2(sidecar, Path(f"{temporary_path}{suffix}"))
|
||||
|
||||
try:
|
||||
connection = sqlite3.connect(
|
||||
f"{temporary_path.resolve().as_uri()}?mode=ro",
|
||||
uri=True,
|
||||
factory=TemporarySQLiteConnection,
|
||||
)
|
||||
except Exception:
|
||||
temporary_directory.cleanup()
|
||||
raise
|
||||
connection.temporary_directory = temporary_directory
|
||||
return connection
|
||||
|
||||
|
||||
def validate_manifest_database(database: Any) -> dict[str, Any]:
|
||||
if not isinstance(database, dict):
|
||||
raise ValueError("database entry is not an object")
|
||||
|
||||
required = ("browser", "package", "profile", "device_path", "archive_path")
|
||||
for field in required:
|
||||
if not isinstance(database.get(field), str) or not database[field]:
|
||||
raise ValueError(f"database entry has invalid {field}")
|
||||
|
||||
archive_path = database["archive_path"]
|
||||
path = Path(archive_path)
|
||||
if (
|
||||
"\\" in archive_path
|
||||
or path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or path.parts[:1] != ("browser_history",)
|
||||
):
|
||||
raise ValueError(f"unsafe browser history archive path: {archive_path}")
|
||||
return database
|
||||
@@ -4,7 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
import pydantic
|
||||
import betterproto2
|
||||
@@ -13,7 +13,6 @@ from dateutil import parser
|
||||
from mvt.android.parsers.proto.tombstone import Tombstone
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from mvt.schemas import OutputModel
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
@@ -74,10 +73,6 @@ class TombstoneCrashResult(pydantic.BaseModel):
|
||||
extra: Optional[str] = None
|
||||
|
||||
|
||||
class TombstoneCrashOutput(pydantic.RootModel[List[TombstoneCrashResult]]):
|
||||
"""Complete JSON document written by tombstone crash modules."""
|
||||
|
||||
|
||||
class TombstoneCrashArtifact(AndroidArtifact):
|
||||
"""
|
||||
Parser for Android tombstone crash files.
|
||||
@@ -85,8 +80,6 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
This parser can parse both text and protobuf tombstone crash files.
|
||||
"""
|
||||
|
||||
output_model: ClassVar[OutputModel] = TombstoneCrashOutput
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
return {
|
||||
"timestamp": record["timestamp"],
|
||||
|
||||
+59
-21
@@ -21,6 +21,7 @@ from mvt.common.help import (
|
||||
HELP_MSG_CHECK_ANDROID_BACKUP,
|
||||
HELP_MSG_CHECK_ANDROIDQF,
|
||||
HELP_MSG_CHECK_BUGREPORT,
|
||||
HELP_MSG_CHECK_FS,
|
||||
HELP_MSG_CHECK_IOCS,
|
||||
HELP_MSG_CHECK_INTRUSION_LOGS,
|
||||
HELP_MSG_DELAY_CHECKS,
|
||||
@@ -98,13 +99,12 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
if ctx.invoked_subcommand not in ("completion", "schemas"):
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.logo import logo
|
||||
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -116,21 +116,6 @@ def version():
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: schemas
|
||||
# ==============================================================================
|
||||
@cli.command("schemas", help="Print or export the JSON Schemas for MVT outputs.")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(file_okay=False, dir_okay=True),
|
||||
help="Write one JSON Schema file per output to this directory.",
|
||||
)
|
||||
def schemas(output):
|
||||
from mvt.common.cmd_schemas import emit_schemas
|
||||
|
||||
emit_schemas("android", output)
|
||||
|
||||
|
||||
# Command: check-adb (removed)
|
||||
# ==============================================================================
|
||||
@cli.command(
|
||||
@@ -377,6 +362,59 @@ def check_androidqf(
|
||||
cmd.show_support_message()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: check-fs
|
||||
# ==============================================================================
|
||||
@cli.command("check-fs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_CHECK_FS)
|
||||
@click.option(
|
||||
"--iocs",
|
||||
"-i",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_IOC,
|
||||
)
|
||||
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.argument("DUMP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_fs(
|
||||
ctx, iocs, output, list_modules, module, load_module, hashes, verbose, dump_path
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
from .cmd_check_fs import CmdAndroidCheckFS
|
||||
|
||||
cmd = CmdAndroidCheckFS(
|
||||
target_path=dump_path,
|
||||
results_path=output,
|
||||
ioc_files=iocs,
|
||||
module_name=module,
|
||||
hashes=hashes,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking Android filesystem located at: %s", dump_path)
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: check-intrusion-logs
|
||||
# ==============================================================================
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.fs import FS_MODULES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdAndroidCheckFS(Command):
|
||||
def __init__(
|
||||
self,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
ioc_files: Optional[list] = None,
|
||||
iocs: Optional[Indicators] = None,
|
||||
module_name: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
hashes: bool = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
ioc_files=ioc_files,
|
||||
iocs=iocs,
|
||||
module_name=module_name,
|
||||
module_options=module_options,
|
||||
hashes=hashes,
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
self.platform = "android"
|
||||
self.name = "check-fs"
|
||||
self.modules = FS_MODULES
|
||||
@@ -11,6 +11,7 @@ from .aqf_log_timestamps import AQFLogTimestamps
|
||||
from .aqf_packages import AQFPackages
|
||||
from .aqf_processes import AQFProcesses
|
||||
from .aqf_settings import AQFSettings
|
||||
from .browser_history import BrowserHistory
|
||||
from .mounts import Mounts
|
||||
from .root_binaries import RootBinaries
|
||||
|
||||
@@ -23,4 +24,5 @@ ANDROIDQF_MODULES: list[type[MVTModule]] = [
|
||||
AQFLogTimestamps,
|
||||
RootBinaries,
|
||||
Mounts,
|
||||
BrowserHistory,
|
||||
]
|
||||
@@ -4,7 +4,6 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
from mvt.schemas import RecordListOutput
|
||||
|
||||
from .base import AndroidQFModule
|
||||
|
||||
@@ -12,8 +11,6 @@ from .base import AndroidQFModule
|
||||
class AQFSettings(SettingsArtifact, AndroidQFModule):
|
||||
"""This module analyse setting files"""
|
||||
|
||||
output_model = RecordListOutput
|
||||
|
||||
def run(self) -> None:
|
||||
for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
|
||||
namespace = setting_file[setting_file.rfind("_") + 1 : -4]
|
||||
|
||||
@@ -7,18 +7,15 @@ import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import zipfile
|
||||
from typing import ClassVar, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
|
||||
class AndroidQFModule(MVTModule):
|
||||
"""This class provides a base for all Android Data analysis modules."""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from mvt.android.artifacts.browser_history import (
|
||||
BrowserHistoryArtifact,
|
||||
open_browser_history_database,
|
||||
validate_manifest_database,
|
||||
)
|
||||
|
||||
from .base import AndroidQFModule
|
||||
|
||||
|
||||
class BrowserHistory(BrowserHistoryArtifact, AndroidQFModule):
|
||||
"""Extract browser visits collected by AndroidQF."""
|
||||
|
||||
supported_commands = (("android", "check-androidqf"),)
|
||||
|
||||
def _find_manifest(self) -> str | None:
|
||||
manifests = [
|
||||
file_path
|
||||
for file_path in self.files
|
||||
if file_path.replace("\\", "/").endswith("browser_history/manifest.json")
|
||||
]
|
||||
if not manifests:
|
||||
return None
|
||||
if len(manifests) > 1:
|
||||
self.log.warning(
|
||||
"Found multiple browser history manifests; using %s", manifests[0]
|
||||
)
|
||||
return manifests[0]
|
||||
|
||||
def _stage_database(
|
||||
self, archive_path: str, prefix: str, temporary_path: Path
|
||||
) -> Path:
|
||||
available_files = {
|
||||
str(PurePosixPath(file_path.replace("\\", "/"))): file_path
|
||||
for file_path in self.files
|
||||
}
|
||||
normalized_path = str(PurePosixPath(prefix, archive_path))
|
||||
source_path = available_files.get(normalized_path)
|
||||
if not source_path:
|
||||
raise FileNotFoundError(archive_path)
|
||||
|
||||
staged_path = temporary_path / "History"
|
||||
staged_path.write_bytes(self._get_file_content(source_path))
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = available_files.get(normalized_path + suffix)
|
||||
if sidecar:
|
||||
Path(f"{staged_path}{suffix}").write_bytes(
|
||||
self._get_file_content(sidecar)
|
||||
)
|
||||
return staged_path
|
||||
|
||||
def run(self) -> None:
|
||||
manifest_path = self._find_manifest()
|
||||
if not manifest_path:
|
||||
self.log.info("No AndroidQF browser history manifest found")
|
||||
return
|
||||
|
||||
try:
|
||||
manifest = json.loads(self._get_file_content(manifest_path))
|
||||
except (json.JSONDecodeError, OSError, TypeError, UnicodeDecodeError) as exc:
|
||||
self.log.error("Unable to read browser history manifest: %s", exc)
|
||||
return
|
||||
|
||||
if not isinstance(manifest, dict) or manifest.get("schema_version") != 1:
|
||||
self.log.error("Unsupported AndroidQF browser history manifest")
|
||||
return
|
||||
databases = manifest.get("databases", [])
|
||||
if not isinstance(databases, list):
|
||||
self.log.error("Invalid AndroidQF browser history database list")
|
||||
return
|
||||
|
||||
normalized_manifest = manifest_path.replace("\\", "/")
|
||||
marker = "browser_history/manifest.json"
|
||||
prefix = normalized_manifest[: -len(marker)].rstrip("/")
|
||||
|
||||
for raw_database in databases:
|
||||
try:
|
||||
database = validate_manifest_database(raw_database)
|
||||
with tempfile.TemporaryDirectory(prefix="mvt_browser_history_") as temp:
|
||||
staged_path = self._stage_database(
|
||||
database["archive_path"], prefix, Path(temp)
|
||||
)
|
||||
connection = open_browser_history_database(staged_path)
|
||||
try:
|
||||
self._parse_browser_history(
|
||||
connection,
|
||||
browser=database["browser"],
|
||||
package=database["package"],
|
||||
profile=database["profile"],
|
||||
source_path=database["device_path"],
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
except (
|
||||
FileNotFoundError,
|
||||
OSError,
|
||||
OverflowError,
|
||||
sqlite3.Error,
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
self.log.error("Unable to parse browser history database: %s", exc)
|
||||
|
||||
self.log.info(
|
||||
"Extracted a total of %d browser history items", len(self.results)
|
||||
)
|
||||
@@ -7,17 +7,14 @@ import fnmatch
|
||||
import logging
|
||||
import os
|
||||
from tarfile import TarFile
|
||||
from typing import ClassVar, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
|
||||
class BackupModule(MVTModule):
|
||||
"""This class provides a base for all backup extractios modules"""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -8,12 +8,11 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, List, Optional
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
|
||||
# section finishes and not necessarily between two sections.
|
||||
@@ -28,8 +27,6 @@ DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
|
||||
class BugReportModule(MVTModule):
|
||||
"""This class provides a base for all Android Bug Report modules."""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.dumpsys_receivers import DumpsysReceiversArtifact
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.schemas import MappingOutput
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
@@ -16,8 +15,6 @@ from .base import BugReportModule
|
||||
class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
"""This module extracts details on receivers for risky activities."""
|
||||
|
||||
output_model = MappingOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .browser_history import BrowserHistory
|
||||
|
||||
FS_MODULES: list[type[MVTModule]] = [BrowserHistory]
|
||||
@@ -0,0 +1,115 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.browser_history import (
|
||||
BrowserHistoryArtifact,
|
||||
open_browser_history_database,
|
||||
)
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
# These locations are deliberately limited to paths backed by public parser
|
||||
# fixtures or the historical MVT implementation.
|
||||
BROWSER_HISTORY_PATHS = {
|
||||
"data/data/com.android.chrome/app_chrome/Default/History": (
|
||||
"Chrome",
|
||||
"com.android.chrome",
|
||||
"Default",
|
||||
),
|
||||
"data/data/com.brave.browser/app_chrome/Default/History": (
|
||||
"Brave",
|
||||
"com.brave.browser",
|
||||
"Default",
|
||||
),
|
||||
"data/data/com.microsoft.emmx/app_chrome/Default/History": (
|
||||
"Microsoft Edge",
|
||||
"com.microsoft.emmx",
|
||||
"Default",
|
||||
),
|
||||
"data/data/com.sec.android.app.sbrowser/app_sbrowser/Default/History": (
|
||||
"Samsung Internet",
|
||||
"com.sec.android.app.sbrowser",
|
||||
"Default",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BrowserHistory(BrowserHistoryArtifact, MVTModule):
|
||||
"""Extract supported Chromium History databases from a filesystem dump."""
|
||||
|
||||
supported_commands = (("android", "check-fs"),)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def _database_paths(self) -> list[tuple[Path, str, str, str]]:
|
||||
if not self.target_path:
|
||||
return []
|
||||
target = Path(self.target_path)
|
||||
if target.is_file():
|
||||
normalized_target = target.resolve().as_posix()
|
||||
for relative_path, identity in BROWSER_HISTORY_PATHS.items():
|
||||
if normalized_target.endswith(f"/{relative_path}"):
|
||||
return [(target, *identity)]
|
||||
return [(target, "Chromium", "unknown", "unknown")]
|
||||
|
||||
databases = []
|
||||
for relative_path, identity in BROWSER_HISTORY_PATHS.items():
|
||||
database_path = target / relative_path
|
||||
if database_path.is_file():
|
||||
databases.append((database_path, *identity))
|
||||
return databases
|
||||
|
||||
def run(self) -> None:
|
||||
for database_path, browser, package, profile in self._database_paths():
|
||||
try:
|
||||
connection = open_browser_history_database(database_path)
|
||||
try:
|
||||
self._parse_browser_history(
|
||||
connection,
|
||||
browser=browser,
|
||||
package=package,
|
||||
profile=profile,
|
||||
source_path=str(database_path),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
except (
|
||||
OSError,
|
||||
OverflowError,
|
||||
sqlite3.Error,
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
self.log.error(
|
||||
"Unable to parse browser history database %s: %s",
|
||||
database_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Extracted a total of %d browser history items", len(self.results)
|
||||
)
|
||||
@@ -9,7 +9,7 @@ import json
|
||||
import logging
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Optional, Union
|
||||
from typing import Optional, Union
|
||||
|
||||
try:
|
||||
import zoneinfo
|
||||
@@ -18,7 +18,6 @@ except ImportError:
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.common.utils import convert_datetime_to_iso, convert_unix_to_iso
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
|
||||
class IntrusionLogsModule(MVTModule):
|
||||
@@ -42,8 +41,6 @@ class IntrusionLogsModule(MVTModule):
|
||||
original file-loading code path.
|
||||
"""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Shared implementation for the platform schema-export commands."""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from mvt.schemas import export_json_schemas, schema_bundle
|
||||
|
||||
|
||||
def emit_schemas(platform: str, output: Optional[str]) -> None:
|
||||
if output:
|
||||
paths = export_json_schemas(output, platform)
|
||||
click.echo(f"Exported {len(paths)} {platform} output schemas to {output}")
|
||||
return
|
||||
|
||||
click.echo(json.dumps(schema_bundle(platform), indent=2))
|
||||
@@ -34,13 +34,6 @@ from .utils import (
|
||||
get_sha256_from_file_path,
|
||||
)
|
||||
from .version import MVT_VERSION
|
||||
from mvt.schemas import (
|
||||
OUTPUT_SCHEMA_VERSION,
|
||||
AlertResults,
|
||||
RunInfo,
|
||||
URLResults,
|
||||
validate_output,
|
||||
)
|
||||
|
||||
|
||||
class Command:
|
||||
@@ -164,8 +157,6 @@ class Command:
|
||||
if not alerts:
|
||||
return
|
||||
|
||||
alerts = validate_output(AlertResults, alerts)
|
||||
|
||||
alerts_path = os.path.join(self.results_path, "alerts.json")
|
||||
with open(alerts_path, "w+", encoding="utf-8") as handle:
|
||||
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
|
||||
@@ -174,10 +165,9 @@ class Command:
|
||||
if not self.results_path or not self.url_results:
|
||||
return
|
||||
|
||||
urls = validate_output(URLResults, self.url_results)
|
||||
urls_path = os.path.join(self.results_path, "urls.json")
|
||||
with open(urls_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(urls, handle, indent=4, cls=CustomJSONEncoder)
|
||||
json.dump(self.url_results, handle, indent=4, cls=CustomJSONEncoder)
|
||||
|
||||
def _store_alerts_timeline(self) -> None:
|
||||
if not self.results_path:
|
||||
@@ -200,7 +190,6 @@ class Command:
|
||||
"date": convert_datetime_to_iso(datetime.now()),
|
||||
"ioc_files": [],
|
||||
"hashes": [],
|
||||
"output_schema_version": OUTPUT_SCHEMA_VERSION,
|
||||
}
|
||||
|
||||
for coll in self.iocs.ioc_collections:
|
||||
@@ -213,7 +202,6 @@ class Command:
|
||||
|
||||
info["hashes"] = self.hash_values
|
||||
|
||||
info = validate_output(RunInfo, info)
|
||||
info_path = os.path.join(self.results_path, "info.json")
|
||||
with open(info_path, "w+", encoding="utf-8") as handle:
|
||||
json.dump(info, handle, indent=4)
|
||||
|
||||
+24
-28
@@ -9,12 +9,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, ClassVar, Dict, Optional, Sequence
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from mvt.schemas.models import AlertResults, GenericModuleOutput, TimelineResults
|
||||
from mvt.schemas.serialization import validate_output
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from .alerts import AlertStore
|
||||
from .indicators import Indicators
|
||||
@@ -51,7 +46,6 @@ class MVTModule:
|
||||
slug: Optional[str] = None
|
||||
dependencies: Sequence[type["MVTModule"]] = ()
|
||||
supported_commands: Sequence[tuple[str, str]] = ()
|
||||
output_model: ClassVar[type[BaseModel]] = GenericModuleOutput
|
||||
# A custom module can name a module class it supersedes, usually a
|
||||
# built-in one. When both are available to a command, the named module is
|
||||
# dropped from the run and this module takes its place, including in the
|
||||
@@ -106,7 +100,7 @@ class MVTModule:
|
||||
@classmethod
|
||||
def from_json(cls, json_path: str, log: logging.Logger):
|
||||
with open(json_path, "r", encoding="utf-8") as handle:
|
||||
results = validate_output(cls.output_model, json.load(handle))
|
||||
results = json.load(handle)
|
||||
if log:
|
||||
log.info('Loaded %d results from "%s"', len(results), json_path)
|
||||
|
||||
@@ -147,35 +141,38 @@ class MVTModule:
|
||||
name = self.get_slug()
|
||||
|
||||
if self.results:
|
||||
converted_results: Any
|
||||
if isinstance(self.results, dict):
|
||||
converted_results = self.results
|
||||
else:
|
||||
converted_results = [
|
||||
asdict(result)
|
||||
if is_dataclass(result) and not isinstance(result, type)
|
||||
else result
|
||||
for result in self.results
|
||||
]
|
||||
results_file_name = f"{name}.json"
|
||||
results_json_path = os.path.join(self.results_path, results_file_name)
|
||||
if self.output_model is GenericModuleOutput:
|
||||
self.log.warning(
|
||||
"Module %s does not declare an output_model; using the "
|
||||
"generic compatibility schema",
|
||||
self.__class__.__name__,
|
||||
)
|
||||
try:
|
||||
converted_results = validate_output(self.output_model, self.results)
|
||||
except (TypeError, ValueError, ValidationError) as exc:
|
||||
self.log.error(
|
||||
"Output from module %s does not match schema %s: %s",
|
||||
self.__class__.__name__,
|
||||
self.output_model.__name__,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
with open(results_json_path, "w", encoding="utf-8") as handle:
|
||||
with open(results_json_path, "w", encoding="utf-8") as handle:
|
||||
try:
|
||||
json.dump(
|
||||
converted_results, handle, indent=4, cls=CustomJSONEncoder
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.error(
|
||||
"Unable to store results of module %s to file %s: %s",
|
||||
self.__class__.__name__,
|
||||
results_file_name,
|
||||
exc,
|
||||
)
|
||||
|
||||
if self.alertstore.alerts:
|
||||
detected_file_name = f"{name}_detected.json"
|
||||
detected_json_path = os.path.join(self.results_path, detected_file_name)
|
||||
alerts = validate_output(AlertResults, self.alertstore.as_json())
|
||||
with open(detected_json_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
|
||||
json.dump(
|
||||
self.alertstore.as_json(), handle, indent=4, cls=CustomJSONEncoder
|
||||
)
|
||||
|
||||
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
raise NotImplementedError
|
||||
@@ -306,7 +303,6 @@ def save_timeline(timeline: list, timeline_path: str, is_utc: bool = True) -> No
|
||||
:param timeline_path: Path to the csv file to store the timeline to
|
||||
|
||||
"""
|
||||
timeline = validate_output(TimelineResults, timeline)
|
||||
with open(timeline_path, "w", encoding="utf-8") as handle:
|
||||
csvoutput = csv.writer(
|
||||
handle, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL, escapechar="\\"
|
||||
|
||||
+5
-21
@@ -110,13 +110,12 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
if ctx.invoked_subcommand not in ("completion", "schemas"):
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.logo import logo
|
||||
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -128,21 +127,6 @@ def version():
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: schemas
|
||||
# ==============================================================================
|
||||
@cli.command("schemas", help="Print or export the JSON Schemas for MVT outputs.")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(file_okay=False, dir_okay=True),
|
||||
help="Write one JSON Schema file per output to this directory.",
|
||||
)
|
||||
def schemas(output):
|
||||
from mvt.common.cmd_schemas import emit_schemas
|
||||
|
||||
emit_schemas("ios", output)
|
||||
|
||||
|
||||
# Command: decrypt-backup
|
||||
# ==============================================================================
|
||||
@cli.command(
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Optional
|
||||
|
||||
from mvt.common.module import DatabaseNotFoundError
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.schemas import MappingOutput
|
||||
from mvt.ios.versions import get_device_desc_from_id, is_ios_version_outdated
|
||||
|
||||
from ..base import IOSExtraction
|
||||
@@ -19,8 +18,6 @@ from ..base import IOSExtraction
|
||||
class BackupInfo(IOSExtraction):
|
||||
"""This module extracts information about the device and the backup."""
|
||||
|
||||
output_model = MappingOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -11,7 +11,7 @@ import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Iterator, Optional, Union
|
||||
from typing import Iterator, Optional, Union
|
||||
|
||||
from mvt.common.module import (
|
||||
DatabaseCorruptedError,
|
||||
@@ -19,7 +19,6 @@ from mvt.common.module import (
|
||||
ModuleResults,
|
||||
MVTModule,
|
||||
)
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
|
||||
class TemporarySQLiteConnection(sqlite3.Connection):
|
||||
@@ -40,8 +39,6 @@ class IOSExtraction(MVTModule):
|
||||
"""This class provides a base for all iOS filesystem/backup extraction
|
||||
modules."""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -13,14 +13,11 @@ from mvt.common.module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.schemas import MappingOutput
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
|
||||
class CacheFiles(IOSExtraction):
|
||||
output_model = MappingOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from mvt.schemas import MappingOutput
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -33,8 +32,6 @@ class WebkitSessionResourceLog(IOSExtraction):
|
||||
|
||||
"""
|
||||
|
||||
output_model = MappingOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Stable public API for MVT output models and JSON Schemas."""
|
||||
|
||||
from .models import (
|
||||
OUTPUT_SCHEMA_VERSION,
|
||||
AlertResult,
|
||||
AlertResults,
|
||||
FileHash,
|
||||
GenericModuleOutput,
|
||||
MappingOutput,
|
||||
ModuleRecord,
|
||||
OutputModel,
|
||||
RecordListOutput,
|
||||
RunInfo,
|
||||
TimelineEvent,
|
||||
TimelineResults,
|
||||
URLResult,
|
||||
URLResults,
|
||||
)
|
||||
from .registry import (
|
||||
export_json_schemas,
|
||||
get_output_model,
|
||||
get_output_models,
|
||||
schema_bundle,
|
||||
)
|
||||
from .serialization import json_compatible, validate_output, write_output
|
||||
|
||||
__all__ = [
|
||||
"OUTPUT_SCHEMA_VERSION",
|
||||
"AlertResult",
|
||||
"AlertResults",
|
||||
"FileHash",
|
||||
"GenericModuleOutput",
|
||||
"MappingOutput",
|
||||
"ModuleRecord",
|
||||
"OutputModel",
|
||||
"RecordListOutput",
|
||||
"RunInfo",
|
||||
"TimelineEvent",
|
||||
"TimelineResults",
|
||||
"URLResult",
|
||||
"URLResults",
|
||||
"export_json_schemas",
|
||||
"get_output_model",
|
||||
"get_output_models",
|
||||
"json_compatible",
|
||||
"schema_bundle",
|
||||
"validate_output",
|
||||
"write_output",
|
||||
]
|
||||
@@ -1,101 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Public Pydantic models for files written by MVT.
|
||||
|
||||
Module records intentionally remain extensible: each extraction module has its own
|
||||
record fields, while the root models make the top-level output shape predictable.
|
||||
More specific modules can replace :class:`ModuleRecord` with a dedicated model.
|
||||
"""
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, RootModel
|
||||
|
||||
OUTPUT_SCHEMA_VERSION: Literal["1.0"] = "1.0"
|
||||
OutputModel = type[BaseModel]
|
||||
|
||||
|
||||
class MVTOutputModel(BaseModel):
|
||||
"""Base for stable output objects with no undocumented fields."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
|
||||
class ModuleRecord(BaseModel):
|
||||
"""A JSON object produced by an extraction module.
|
||||
|
||||
Module-specific keys are retained and represented in JSON Schema through
|
||||
``additionalProperties``. Dedicated module models can provide stronger field
|
||||
contracts without changing the serialization machinery.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow", strict=True)
|
||||
|
||||
|
||||
class RecordListOutput(RootModel[list[ModuleRecord]]):
|
||||
"""The standard output shape for built-in extraction modules."""
|
||||
|
||||
|
||||
class MappingOutput(RootModel[dict[str, JsonValue]]):
|
||||
"""Output shape for modules grouped by a dynamic string key."""
|
||||
|
||||
|
||||
class GenericModuleOutput(RootModel[list[JsonValue] | dict[str, JsonValue]]):
|
||||
"""Compatibility output shape for third-party modules without a model."""
|
||||
|
||||
|
||||
class FileHash(MVTOutputModel):
|
||||
file_path: str
|
||||
sha256: str
|
||||
|
||||
|
||||
class RunInfo(MVTOutputModel):
|
||||
target_path: Optional[str]
|
||||
mvt_version: str
|
||||
date: str
|
||||
ioc_files: list[str]
|
||||
hashes: list[FileHash]
|
||||
output_schema_version: Literal["1.0"]
|
||||
|
||||
|
||||
class URLResult(MVTOutputModel):
|
||||
url: str
|
||||
expanded_url: Optional[str]
|
||||
timestamp: Optional[str]
|
||||
source: str
|
||||
|
||||
|
||||
class URLResults(RootModel[list[URLResult]]):
|
||||
pass
|
||||
|
||||
|
||||
AlertLevelName = Literal["INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"]
|
||||
|
||||
|
||||
class AlertResult(MVTOutputModel):
|
||||
level: AlertLevelName
|
||||
module: str
|
||||
message: str
|
||||
event_time: str
|
||||
event: dict[str, JsonValue]
|
||||
matched_indicator: Optional[JsonValue] = None
|
||||
|
||||
|
||||
class AlertResults(RootModel[list[AlertResult]]):
|
||||
pass
|
||||
|
||||
|
||||
class TimelineEvent(MVTOutputModel):
|
||||
"""Public contract for the records used to create ``timeline.csv``."""
|
||||
|
||||
timestamp: Optional[str]
|
||||
module: str
|
||||
event: str
|
||||
data: str
|
||||
|
||||
|
||||
class TimelineResults(RootModel[list[TimelineEvent]]):
|
||||
pass
|
||||
@@ -1,133 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Discovery and JSON Schema export for MVT output models."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Iterable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .models import (
|
||||
OUTPUT_SCHEMA_VERSION,
|
||||
AlertResults,
|
||||
RunInfo,
|
||||
TimelineResults,
|
||||
URLResults,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
COMMON_OUTPUT_MODELS: dict[str, type[BaseModel]] = {
|
||||
"alerts": AlertResults,
|
||||
"info": RunInfo,
|
||||
"timeline": TimelineResults,
|
||||
"urls": URLResults,
|
||||
}
|
||||
|
||||
JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
||||
|
||||
|
||||
def _module_classes(platform: str) -> Iterable[type["MVTModule"]]:
|
||||
if platform == "ios":
|
||||
from mvt.ios.modules.backup import BACKUP_MODULES as IOS_BACKUP_MODULES
|
||||
from mvt.ios.modules.fs import FS_MODULES
|
||||
from mvt.ios.modules.mixed import MIXED_MODULES
|
||||
|
||||
return IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
if platform == "android":
|
||||
from mvt.android.modules.androidqf import ANDROIDQF_MODULES
|
||||
from mvt.android.modules.backup import BACKUP_MODULES as ANDROID_BACKUP_MODULES
|
||||
from mvt.android.modules.bugreport import BUGREPORT_MODULES
|
||||
from mvt.android.modules.intrusion_logs import INTRUSION_LOGS_MODULES
|
||||
|
||||
return (
|
||||
ANDROID_BACKUP_MODULES
|
||||
+ BUGREPORT_MODULES
|
||||
+ ANDROIDQF_MODULES
|
||||
+ INTRUSION_LOGS_MODULES
|
||||
)
|
||||
raise ValueError(f"Unsupported MVT platform: {platform}")
|
||||
|
||||
|
||||
def get_output_models(platform: str) -> dict[str, type[BaseModel]]:
|
||||
"""Return common and module output models available for a platform."""
|
||||
|
||||
models = dict(COMMON_OUTPUT_MODELS)
|
||||
for module in _module_classes(platform):
|
||||
models[module.get_slug()] = module.output_model
|
||||
return dict(sorted(models.items()))
|
||||
|
||||
|
||||
def get_output_model(name: str, platform: str | None = None) -> type[BaseModel]:
|
||||
"""Look up an output model by file stem/module slug.
|
||||
|
||||
``platform`` is optional for common outputs. It is required when an output
|
||||
slug exists on both platforms and resolves to different models.
|
||||
"""
|
||||
|
||||
normalized = name.removesuffix(".json")
|
||||
if normalized.endswith("_detected"):
|
||||
return AlertResults
|
||||
if normalized in COMMON_OUTPUT_MODELS:
|
||||
return COMMON_OUTPUT_MODELS[normalized]
|
||||
if platform:
|
||||
try:
|
||||
return get_output_models(platform)[normalized]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"Unknown {platform} output schema: {name}") from exc
|
||||
|
||||
matches = {
|
||||
models[normalized]
|
||||
for candidate in ("ios", "android")
|
||||
if normalized in (models := get_output_models(candidate))
|
||||
}
|
||||
if len(matches) == 1:
|
||||
return matches.pop()
|
||||
if len(matches) > 1:
|
||||
raise KeyError(f"Output schema {name!r} is ambiguous; specify a platform")
|
||||
raise KeyError(f"Unknown MVT output schema: {name}")
|
||||
|
||||
|
||||
def schema_bundle(platform: str) -> dict[str, Any]:
|
||||
"""Build a versioned bundle of Draft 2020-12 JSON Schemas."""
|
||||
|
||||
return {
|
||||
"output_schema_version": OUTPUT_SCHEMA_VERSION,
|
||||
"platform": platform,
|
||||
"schemas": {
|
||||
name: _output_json_schema(name, model, platform)
|
||||
for name, model in get_output_models(platform).items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _output_json_schema(
|
||||
name: str, model: type[BaseModel], platform: str
|
||||
) -> dict[str, Any]:
|
||||
schema = model.model_json_schema()
|
||||
return {
|
||||
"$schema": JSON_SCHEMA_DIALECT,
|
||||
"$id": f"urn:mvt:output-schema:{OUTPUT_SCHEMA_VERSION}:{platform}:{name}",
|
||||
**schema,
|
||||
}
|
||||
|
||||
|
||||
def export_json_schemas(destination: str | os.PathLike[str], platform: str) -> list[Path]:
|
||||
"""Write one JSON Schema per output and return the paths created."""
|
||||
|
||||
destination_path = Path(destination)
|
||||
destination_path.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
for name, model in get_output_models(platform).items():
|
||||
output_path = destination_path / f"{name}.schema.json"
|
||||
with output_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(_output_json_schema(name, model, platform), handle, indent=2)
|
||||
handle.write("\n")
|
||||
written.append(output_path)
|
||||
return written
|
||||
@@ -1,35 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Validation and serialization helpers shared by all MVT output writers."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mvt.common.utils import CustomJSONEncoder
|
||||
|
||||
|
||||
def json_compatible(value: Any) -> Any:
|
||||
"""Apply MVT's legacy conversions before validating JSON-native values."""
|
||||
|
||||
return json.loads(json.dumps(value, cls=CustomJSONEncoder))
|
||||
|
||||
|
||||
def validate_output(model: type[BaseModel], value: Any) -> Any:
|
||||
"""Validate an output document and return JSON-serializable Python values."""
|
||||
|
||||
compatible = json_compatible(value)
|
||||
return model.model_validate(compatible).model_dump(mode="json")
|
||||
|
||||
|
||||
def write_output(path: str | Path, model: type[BaseModel], value: Any) -> None:
|
||||
"""Validate and write an MVT JSON document."""
|
||||
|
||||
validated = validate_output(model, value)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(validated, handle, indent=4)
|
||||
@@ -0,0 +1,261 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mvt.android.modules.androidqf.browser_history import (
|
||||
BrowserHistory as AndroidQFBrowserHistory,
|
||||
)
|
||||
from mvt.android.modules.fs.browser_history import BrowserHistory as FSBrowserHistory
|
||||
from mvt.android.cmd_check_fs import CmdAndroidCheckFS
|
||||
|
||||
CHROME_TIME = 13_348_540_800_000_000
|
||||
URL = "https://example.org/path"
|
||||
|
||||
|
||||
def create_history_database(path: Path, *, url: str = URL) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE urls (
|
||||
id INTEGER PRIMARY KEY,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
visit_count INTEGER,
|
||||
typed_count INTEGER
|
||||
);
|
||||
CREATE TABLE visits (
|
||||
id INTEGER PRIMARY KEY,
|
||||
url INTEGER,
|
||||
visit_time INTEGER,
|
||||
from_visit INTEGER,
|
||||
transition INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute("INSERT INTO urls VALUES (1, ?, 'Example', 1, 0)", (url,))
|
||||
connection.execute(
|
||||
"INSERT INTO visits VALUES (7, 1, ?, 0, 805306368)",
|
||||
(CHROME_TIME,),
|
||||
)
|
||||
|
||||
|
||||
def manifest(database_paths: list[tuple[str, str]]) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"status": "collected",
|
||||
"databases": [
|
||||
{
|
||||
"browser": "Chrome",
|
||||
"package": package,
|
||||
"profile": "Default",
|
||||
"device_path": f"/data/data/{package}/app_chrome/Default/History",
|
||||
"archive_path": archive_path,
|
||||
"sidecars": [],
|
||||
}
|
||||
for package, archive_path in database_paths
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_zip", [False, True])
|
||||
def test_androidqf_browser_history_directory_and_zip(tmp_path, use_zip):
|
||||
source_database = tmp_path / "source" / "History"
|
||||
create_history_database(source_database)
|
||||
archive_path = "browser_history/com.android.chrome/Default/History"
|
||||
manifest_data = manifest([("com.android.chrome", archive_path)])
|
||||
|
||||
module = AndroidQFBrowserHistory()
|
||||
if use_zip:
|
||||
acquisition_path = tmp_path / "acquisition.zip"
|
||||
with zipfile.ZipFile(acquisition_path, "w") as archive:
|
||||
archive.write(source_database, archive_path)
|
||||
archive.writestr("browser_history/manifest.json", json.dumps(manifest_data))
|
||||
with zipfile.ZipFile(acquisition_path) as archive:
|
||||
module.from_zip(archive, archive.namelist())
|
||||
module.run()
|
||||
else:
|
||||
acquisition = tmp_path / "acquisition"
|
||||
database_path = acquisition / archive_path
|
||||
database_path.parent.mkdir(parents=True)
|
||||
database_path.write_bytes(source_database.read_bytes())
|
||||
manifest_path = acquisition / "browser_history" / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest_data))
|
||||
files = [
|
||||
path.relative_to(tmp_path).as_posix()
|
||||
for path in acquisition.rglob("*")
|
||||
if path.is_file()
|
||||
]
|
||||
module.from_dir(str(tmp_path), files)
|
||||
module.run()
|
||||
|
||||
assert len(module.results) == 1
|
||||
assert module.results[0]["url"] == URL
|
||||
assert module.results[0]["browser"] == "Chrome"
|
||||
assert module.results[0]["source_path"].endswith("/History")
|
||||
module.collect_url_results()
|
||||
module.to_timeline()
|
||||
assert module.url_results[0]["url"] == URL
|
||||
assert module.timeline[0]["event"] == "browser_history"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["", "./", "acquisition/", "./acquisition/"])
|
||||
@pytest.mark.parametrize("separator", ["/", "\\"])
|
||||
def test_androidqf_zip_paths_preserve_wal_visits(tmp_path, prefix, separator):
|
||||
database = tmp_path / "History"
|
||||
create_history_database(database)
|
||||
connection = sqlite3.connect(database)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
connection.execute(
|
||||
"INSERT INTO urls VALUES (2, ?, 'WAL', 1, 0)", (URL + "/wal",)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO visits VALUES (8, 2, ?, 0, 805306368)", (CHROME_TIME,)
|
||||
)
|
||||
connection.commit()
|
||||
archive_path = "browser_history/com.android.chrome/Default/History"
|
||||
acquisition = tmp_path / "acquisition.zip"
|
||||
try:
|
||||
with zipfile.ZipFile(acquisition, "w") as archive:
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
archive.writestr(
|
||||
(prefix + archive_path + suffix).replace("/", separator),
|
||||
Path(f"{database}{suffix}").read_bytes(),
|
||||
)
|
||||
archive.writestr(
|
||||
(prefix + "browser_history/manifest.json").replace("/", separator),
|
||||
json.dumps(manifest([("com.android.chrome", archive_path)])),
|
||||
)
|
||||
module = AndroidQFBrowserHistory()
|
||||
with zipfile.ZipFile(acquisition) as archive:
|
||||
module.from_zip(archive, archive.namelist())
|
||||
module.run()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
assert {result["url"] for result in module.results} == {URL, URL + "/wal"}
|
||||
|
||||
|
||||
def test_androidqf_browser_history_isolates_malformed_database(tmp_path, caplog):
|
||||
acquisition = tmp_path / "acquisition"
|
||||
good_archive_path = "browser_history/com.android.chrome/Default/History"
|
||||
bad_archive_path = "browser_history/com.brave.browser/Default/History"
|
||||
create_history_database(acquisition / good_archive_path)
|
||||
bad_path = acquisition / bad_archive_path
|
||||
bad_path.parent.mkdir(parents=True)
|
||||
bad_path.write_bytes(b"not sqlite")
|
||||
manifest_path = acquisition / "browser_history" / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
manifest(
|
||||
[
|
||||
("com.brave.browser", bad_archive_path),
|
||||
("com.android.chrome", good_archive_path),
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
module = AndroidQFBrowserHistory()
|
||||
files = [
|
||||
path.relative_to(tmp_path).as_posix()
|
||||
for path in acquisition.rglob("*")
|
||||
if path.is_file()
|
||||
]
|
||||
module.from_dir(str(tmp_path), files)
|
||||
module.run()
|
||||
|
||||
assert [result["url"] for result in module.results] == [URL]
|
||||
assert "Unable to parse browser history database" in caplog.text
|
||||
|
||||
|
||||
def test_androidqf_browser_history_rejects_unsafe_manifest_path(tmp_path, caplog):
|
||||
acquisition = tmp_path / "acquisition"
|
||||
manifest_path = acquisition / "browser_history" / "manifest.json"
|
||||
manifest_path.parent.mkdir(parents=True)
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest([("com.android.chrome", "browser_history/../secret")]))
|
||||
)
|
||||
module = AndroidQFBrowserHistory()
|
||||
module.from_dir(str(tmp_path), [manifest_path.relative_to(tmp_path).as_posix()])
|
||||
|
||||
module.run()
|
||||
|
||||
assert module.results == []
|
||||
assert "unsafe browser history archive path" in caplog.text
|
||||
|
||||
|
||||
def test_filesystem_browser_history_reads_wal_only_visit(tmp_path):
|
||||
database_path = tmp_path / "History"
|
||||
connection = sqlite3.connect(database_path)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE urls (
|
||||
id INTEGER PRIMARY KEY,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
visit_count INTEGER,
|
||||
typed_count INTEGER
|
||||
);
|
||||
CREATE TABLE visits (
|
||||
id INTEGER PRIMARY KEY,
|
||||
url INTEGER,
|
||||
visit_time INTEGER,
|
||||
from_visit INTEGER,
|
||||
transition INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
connection.execute("INSERT INTO urls VALUES (1, ?, 'WAL', 1, 0)", (URL,))
|
||||
connection.execute(
|
||||
"INSERT INTO visits VALUES (8, 1, ?, 0, 805306368)", (CHROME_TIME,)
|
||||
)
|
||||
connection.commit()
|
||||
try:
|
||||
assert Path(f"{database_path}-wal").stat().st_size > 0
|
||||
module = FSBrowserHistory(target_path=str(database_path))
|
||||
module.run()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
assert [result["url"] for result in module.results] == [URL]
|
||||
assert module.results[0]["browser"] == "Chromium"
|
||||
|
||||
|
||||
def test_android_check_fs_finds_all_supported_browser_paths(tmp_path):
|
||||
for index, relative_path in enumerate(
|
||||
(
|
||||
"data/data/com.android.chrome/app_chrome/Default/History",
|
||||
"data/data/com.brave.browser/app_chrome/Default/History",
|
||||
"data/data/com.microsoft.emmx/app_chrome/Default/History",
|
||||
"data/data/com.sec.android.app.sbrowser/app_sbrowser/Default/History",
|
||||
)
|
||||
):
|
||||
create_history_database(
|
||||
tmp_path / relative_path, url=f"https://example.org/{index}"
|
||||
)
|
||||
|
||||
command = CmdAndroidCheckFS(target_path=str(tmp_path), module_name="BrowserHistory")
|
||||
command.run()
|
||||
|
||||
assert len(command.executed) == 1
|
||||
assert {result["package"] for result in command.executed[0].results} == {
|
||||
"com.android.chrome",
|
||||
"com.brave.browser",
|
||||
"com.microsoft.emmx",
|
||||
"com.sec.android.app.sbrowser",
|
||||
}
|
||||
assert len(command.url_results) == 4
|
||||
assert len(command.timeline) == 4
|
||||
@@ -1,131 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError
|
||||
|
||||
from mvt.android.cli import cli as android_cli
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
from mvt.schemas import (
|
||||
OUTPUT_SCHEMA_VERSION,
|
||||
GenericModuleOutput,
|
||||
MappingOutput,
|
||||
RecordListOutput,
|
||||
export_json_schemas,
|
||||
get_output_model,
|
||||
get_output_models,
|
||||
schema_bundle,
|
||||
validate_output,
|
||||
)
|
||||
|
||||
|
||||
class CountRecord(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
count: int
|
||||
|
||||
|
||||
class CountOutput(RootModel[list[CountRecord]]):
|
||||
pass
|
||||
|
||||
|
||||
class CountModule(MVTModule):
|
||||
output_model = CountOutput
|
||||
|
||||
|
||||
def test_common_output_model_validates_without_changing_root_shape():
|
||||
assert validate_output(RecordListOutput, [{"name": "example", "size": 2}]) == [
|
||||
{"name": "example", "size": 2}
|
||||
]
|
||||
assert validate_output(MappingOutput, {"global": {"enabled": "1"}}) == {
|
||||
"global": {"enabled": "1"}
|
||||
}
|
||||
|
||||
|
||||
def test_module_model_is_used_when_loading_existing_output(tmp_path):
|
||||
path = tmp_path / "count.json"
|
||||
path.write_text('[{"count": 4}]', encoding="utf-8")
|
||||
|
||||
module = CountModule.from_json(str(path), logging.getLogger(__name__))
|
||||
|
||||
assert module.results == [{"count": 4}]
|
||||
|
||||
|
||||
def test_invalid_module_output_is_not_written(tmp_path, caplog):
|
||||
module = CountModule(results_path=str(tmp_path), results=[{"count": "four"}])
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
module.save_to_json()
|
||||
|
||||
assert not (tmp_path / "count_module.json").exists()
|
||||
assert "does not match schema CountOutput" in caplog.text
|
||||
|
||||
|
||||
def test_invalid_existing_output_raises_validation_error(tmp_path):
|
||||
path = tmp_path / "count.json"
|
||||
path.write_text('[{"count": "four"}]', encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CountModule.from_json(str(path), logging.getLogger(__name__))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["ios", "android"])
|
||||
def test_every_builtin_module_has_a_non_generic_output_model(platform):
|
||||
module_models = {
|
||||
name: model
|
||||
for name, model in get_output_models(platform).items()
|
||||
if name not in {"alerts", "info", "timeline", "urls"}
|
||||
}
|
||||
|
||||
assert module_models
|
||||
assert GenericModuleOutput not in module_models.values()
|
||||
|
||||
|
||||
def test_known_mapping_and_specific_models_are_registered():
|
||||
assert get_output_model("backup_info.json", "ios") is MappingOutput
|
||||
assert get_output_model("dumpsys_receivers", "android") is MappingOutput
|
||||
assert get_output_model("tombstones", "android").__name__ == "TombstoneCrashOutput"
|
||||
assert get_output_model("sms_detected.json", "ios").__name__ == "AlertResults"
|
||||
|
||||
|
||||
def test_schema_bundle_has_stable_versioned_shape():
|
||||
bundle = schema_bundle("android")
|
||||
|
||||
assert bundle["output_schema_version"] == OUTPUT_SCHEMA_VERSION
|
||||
assert bundle["platform"] == "android"
|
||||
assert bundle["schemas"]["urls"]["$schema"].endswith("2020-12/schema")
|
||||
assert bundle["schemas"]["urls"]["type"] == "array"
|
||||
assert bundle["schemas"]["info"]["additionalProperties"] is False
|
||||
assert bundle["schemas"]["dumpsys_receivers"]["type"] == "object"
|
||||
|
||||
|
||||
def test_export_json_schemas_writes_one_file_per_registered_output(tmp_path):
|
||||
paths = export_json_schemas(tmp_path, "android")
|
||||
|
||||
assert len(paths) == len(get_output_models("android"))
|
||||
assert json.loads((tmp_path / "info.schema.json").read_text())["title"] == (
|
||||
"RunInfo"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cli", "platform"), [(ios_cli, "ios"), (android_cli, "android")]
|
||||
)
|
||||
def test_schema_cli_prints_machine_readable_bundle(cli, platform):
|
||||
result = CliRunner().invoke(cli, ["schemas"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output)["platform"] == platform
|
||||
|
||||
|
||||
def test_schema_cli_exports_schema_files(tmp_path):
|
||||
result = CliRunner().invoke(android_cli, ["schemas", "--output", str(tmp_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (tmp_path / "urls.schema.json").exists()
|
||||
Reference in new issue
Block a user