Compare commits

..
32 changed files with 828 additions and 681 deletions

No files matched your search

+21
View File
@@ -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.
-6
View File
@@ -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.
-77
View File
@@ -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.
-1
View File
@@ -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
View File
@@ -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
# ==============================================================================
+47
View File
@@ -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]
+1 -4
View File
@@ -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)
)
+1 -4
View File
@@ -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,
+1 -4
View File
@@ -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,
+10
View File
@@ -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,
-22
View File
@@ -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))
+1 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1 -4
View File
@@ -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,
-3
View File
@@ -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,
-49
View File
@@ -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",
]
-101
View File
@@ -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
-133
View File
@@ -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
-35
View File
@@ -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)
+261
View File
@@ -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
-131
View File
@@ -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()