mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-23 23:58:23 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92683ef788 | ||
|
|
a02c7d8db0 |
No files matched your search
@@ -34,7 +34,6 @@ jobs:
|
||||
publish:
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi # only tag refs may deploy; the PyPI trusted publisher requires it
|
||||
permissions:
|
||||
id-token: write # PyPI trusted publishing
|
||||
steps:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
prune tests
|
||||
prune docs
|
||||
prune .github
|
||||
exclude uv.lock Dockerfile* mkdocs.yml .readthedocs.yaml .safety-policy.yml .python-version .gitignore Makefile
|
||||
@@ -435,11 +435,3 @@ This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extr
|
||||
|
||||
This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Records extracted by `check-sysdiagnose`
|
||||
|
||||
### `sysdiagnose_info.json`
|
||||
|
||||
This JSON file is created by mvt-ios' `SysdiagnoseInfo` module. The module extracts details about the device and the sysdiagnose itself: the product type and model, iOS version and build, serial number, IMEI, MEID and UDID from the remotectl dump state and the mobile activation request, the Apple account name and email from the App Store daemon database (no longer part of a sysdiagnose on newer iOS versions, still read from older archives), and the original file name and creation time of the archive from *sysdiagnose.log*.
|
||||
@@ -1,13 +1,10 @@
|
||||
# Check an iOS Sysdiagnose
|
||||
|
||||
`mvt-ios check-sysdiagnose` analyzes an iOS sysdiagnose archive. MVT's own
|
||||
`SysdiagnoseInfo` module extracts details about the device and the archive
|
||||
(see [`sysdiagnose_info.json`](records.md#sysdiagnose_infojson)); the checks
|
||||
come from the modules of the installed
|
||||
`mvt-ios check-sysdiagnose` prepares an iOS sysdiagnose archive for analysis by
|
||||
custom MVT modules. MVT does not include built-in sysdiagnose modules. The
|
||||
command runs the modules of the installed
|
||||
[plugin packages](../development/index.md#installed-module-packages) which
|
||||
declare support for the command. Without any such module the command still
|
||||
records the device details, and warns that no forensic sysdiagnose modules
|
||||
have been loaded so that the run cannot pass for a clean analysis.
|
||||
declare support for it. Install at least one such package first.
|
||||
|
||||
The command accepts either an extracted sysdiagnose directory or the original
|
||||
gzip-compressed tar archive.
|
||||
|
||||
+3
-1
@@ -24,8 +24,10 @@ dependencies = [
|
||||
"simplejson==4.1.1",
|
||||
"packaging==26.3",
|
||||
"appdirs==1.4.4",
|
||||
"iphone_backup_decrypt==0.10.0",
|
||||
"iphone_backup_decrypt==0.9.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
"adb-shell[usb]==0.4.4",
|
||||
"libusb1==3.4.0",
|
||||
"cryptography==50.0.0",
|
||||
"PyYAML>=6.0.2",
|
||||
"pyahocorasick==2.3.1",
|
||||
|
||||
@@ -136,10 +136,6 @@ class Mounts(AndroidArtifact):
|
||||
mount_options = fields[5].split(",")
|
||||
super_options = fields[separator + 3].split(",")
|
||||
options = list(dict.fromkeys(mount_options + super_options))
|
||||
# fields[5] are the per-mount (VFS) flags, the field after the
|
||||
# separator the superblock's; a mount is writable only if both
|
||||
# allow it. The merged list made every read-only overlay a HIGH.
|
||||
is_read_write = "rw" in mount_options and "rw" in super_options
|
||||
mount_point = fields[4].replace("\\040", " ")
|
||||
device = fields[separator + 2].replace("\\040", " ")
|
||||
filesystem_type = fields[separator + 1]
|
||||
@@ -160,7 +156,7 @@ class Mounts(AndroidArtifact):
|
||||
"options_list": options,
|
||||
"optional_fields": fields[6:separator],
|
||||
"is_system_partition": is_system,
|
||||
"is_read_write": is_read_write,
|
||||
"is_read_write": "rw" in options,
|
||||
"process_ids": [process_id],
|
||||
}
|
||||
)
|
||||
@@ -195,14 +191,7 @@ class Mounts(AndroidArtifact):
|
||||
)
|
||||
|
||||
# Check for other suspicious mount options
|
||||
# `rw` sitting in the merged option list is not evidence of
|
||||
# writability (see is_read_write in parse_mountinfo); every other
|
||||
# suspicious option is meaningful in either list.
|
||||
suspicious_opts = [
|
||||
opt
|
||||
for opt in options
|
||||
if opt in SUSPICIOUS_OPTIONS and (opt != "rw" or mount["is_read_write"])
|
||||
]
|
||||
suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS]
|
||||
if suspicious_opts and mount["is_system_partition"]:
|
||||
if (
|
||||
"noatime" in mount["mount_options"]
|
||||
|
||||
+8
-17
@@ -150,16 +150,6 @@ def check_adb(ctx):
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option(
|
||||
"--timezone",
|
||||
"-t",
|
||||
default=None,
|
||||
help=(
|
||||
"IANA timezone name for the device, for example 'Europe/Paris'. "
|
||||
"Bugreport file timestamps are the device's wall clock; by default the "
|
||||
"zone is read from persist.sys.timezone in the bugreport itself."
|
||||
),
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
@@ -170,7 +160,6 @@ def check_bugreport(
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
timezone,
|
||||
verbose,
|
||||
bugreport_path,
|
||||
):
|
||||
@@ -178,18 +167,12 @@ def check_bugreport(
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
module_options = {}
|
||||
if timezone:
|
||||
module_options["device_timezone"] = timezone
|
||||
|
||||
# Always generate hashes as bug reports are small.
|
||||
cmd = CmdAndroidCheckBugreport(
|
||||
target_path=bugreport_path,
|
||||
results_path=output,
|
||||
ioc_files=iocs,
|
||||
module_name=module,
|
||||
module_options=module_options if module_options else None,
|
||||
hashes=True,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
@@ -200,6 +183,8 @@ def check_bugreport(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking Android bug report at path: %s", bugreport_path)
|
||||
|
||||
try:
|
||||
cmd.run()
|
||||
except BadZipFile as exc:
|
||||
@@ -274,6 +259,8 @@ def check_backup(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking Android backup at path: %s", backup_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
@@ -355,6 +342,8 @@ def check_androidqf(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking AndroidQF acquisition at path: %s", androidqf_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_disable_adb_warning()
|
||||
@@ -435,6 +424,8 @@ def check_intrusion_logs(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking intrusion logs at path: %s", logs_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
@@ -82,8 +82,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
if not self.target_path:
|
||||
raise NoAndroidQFTargetPath
|
||||
|
||||
self.log.info("Checking AndroidQF acquisition at path: %s", self.target_path)
|
||||
|
||||
if os.path.isdir(self.target_path):
|
||||
self.__format = "dir"
|
||||
parent_path = Path(self.target_path).absolute().parent.as_posix()
|
||||
|
||||
@@ -129,7 +129,6 @@ class CmdAndroidCheckBackup(Command):
|
||||
assert self.target_path is not None # type: ignore[has-type]
|
||||
# Use a different local variable name to avoid any scoping issues
|
||||
backup_path: str = self.target_path # type: ignore[has-type]
|
||||
self.log.info("Checking Android backup at path: %s", backup_path)
|
||||
|
||||
if os.path.isfile(backup_path):
|
||||
self.__type = "ab"
|
||||
|
||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
|
||||
from mvt.android.artifacts.getprop import GetProp
|
||||
from mvt.android.modules.bugreport.base import BugReportModule
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
@@ -89,57 +88,13 @@ class CmdAndroidCheckBugreport(Command):
|
||||
self.__files.append(file_name)
|
||||
|
||||
def init(self) -> None:
|
||||
if self.target_path:
|
||||
self.log.info("Checking Android bug report at path: %s", self.target_path)
|
||||
if os.path.isfile(self.target_path):
|
||||
self.from_zip(ZipFile(self.target_path))
|
||||
elif os.path.isdir(self.target_path):
|
||||
self.from_dir(self.target_path)
|
||||
self.log.warning(
|
||||
"Analysing an unpacked bugreport: file timestamps come from "
|
||||
"the extraction, not from the device. Analyse the original "
|
||||
"zip to keep the device's file timestamps."
|
||||
)
|
||||
if self.__format:
|
||||
self._resolve_device_timezone()
|
||||
|
||||
def _resolve_device_timezone(self) -> None:
|
||||
"""Name the device's timezone in module_options unless it is known already.
|
||||
|
||||
A bugreport's SYSTEM PROPERTIES section carries persist.sys.timezone.
|
||||
Zip entry times are the device's wall clock, and modules read them in
|
||||
this zone; --timezone or check-androidqf's own reading takes precedence.
|
||||
"""
|
||||
if self.module_options.get("device_timezone"):
|
||||
self.log.info("Device timezone: %s", self.module_options["device_timezone"])
|
||||
if not self.target_path:
|
||||
return
|
||||
|
||||
probe = BugReportModule(log=self.log)
|
||||
self.module_init(probe)
|
||||
timezone = None
|
||||
try:
|
||||
dumpstate = probe._get_dumpstate_file()
|
||||
except Exception as exc:
|
||||
self.log.warning("Could not read the bugreport's dumpstate: %s", exc)
|
||||
dumpstate = None
|
||||
if dumpstate:
|
||||
properties = GetProp()
|
||||
properties.parse(
|
||||
BugReportModule.extract_command_section(
|
||||
dumpstate.decode("utf-8", errors="replace"),
|
||||
"------ SYSTEM PROPERTIES",
|
||||
)
|
||||
)
|
||||
timezone = properties.get_device_timezone()
|
||||
if timezone:
|
||||
self.log.info("Device timezone identified from the bugreport: %s", timezone)
|
||||
self.module_options["device_timezone"] = timezone
|
||||
else:
|
||||
self.log.warning(
|
||||
"persist.sys.timezone not found in the bugreport; file timestamps "
|
||||
"are the device's wall clock without a timezone. Pass --timezone "
|
||||
"to name it."
|
||||
)
|
||||
if os.path.isfile(self.target_path):
|
||||
self.from_zip(ZipFile(self.target_path))
|
||||
elif os.path.isdir(self.target_path):
|
||||
self.from_dir(self.target_path)
|
||||
|
||||
def module_init(self, module: BugReportModule) -> None: # type: ignore[override]
|
||||
if self.__format == "zip":
|
||||
|
||||
@@ -63,8 +63,6 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
||||
if not self.target_path:
|
||||
raise ValueError("No target path specified")
|
||||
|
||||
self.log.info("Checking intrusion logs at path: %s", self.target_path)
|
||||
|
||||
if not os.path.isdir(self.target_path) and not (
|
||||
os.path.isfile(self.target_path)
|
||||
and self.target_path.lower().endswith(".zip")
|
||||
|
||||
@@ -9,7 +9,6 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
|
||||
@@ -126,31 +125,12 @@ class BugReportModule(MVTModule):
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _device_timezone(self) -> Optional[datetime.tzinfo]:
|
||||
"""The device's timezone named in module_options, or None when unknown."""
|
||||
name = self.module_options.get("device_timezone")
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except ZoneInfoNotFoundError:
|
||||
self.log.warning("Unknown device timezone %s", name)
|
||||
return None
|
||||
|
||||
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
|
||||
"""When the file was last modified.
|
||||
|
||||
A zip entry carries the device's wall clock, so it is returned in the
|
||||
device's timezone when the bugreport names one and naive otherwise.
|
||||
An unpacked bugreport's mtime is whatever the extraction left, an
|
||||
instant returned in UTC.
|
||||
"""
|
||||
if self.zip_archive:
|
||||
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
||||
return datetime.datetime(*file_timetuple, tzinfo=self._device_timezone())
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
file_stat = os.stat(os.path.join(self.extract_path, file_path))
|
||||
return datetime.datetime.fromtimestamp(
|
||||
file_stat.st_mtime, tz=datetime.timezone.utc
|
||||
)
|
||||
return datetime.datetime(*file_timetuple)
|
||||
else:
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
file_stat = os.stat(os.path.join(self.extract_path, file_path))
|
||||
return datetime.datetime.fromtimestamp(file_stat.st_mtime)
|
||||
@@ -4,12 +4,15 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from .base import BugReportModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
|
||||
from mvt.android.artifacts.getprop import GetProp
|
||||
|
||||
|
||||
class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
|
||||
@@ -38,28 +41,41 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
|
||||
def run(self) -> None:
|
||||
filesystem_files = self._get_files_by_pattern("FS/*")
|
||||
|
||||
timezone_name = None
|
||||
dumpstate = self._get_dumpstate_file()
|
||||
if dumpstate:
|
||||
section = self.extract_command_section(
|
||||
dumpstate.decode("utf-8", errors="replace"),
|
||||
"------ SYSTEM PROPERTIES",
|
||||
)
|
||||
properties = GetProp()
|
||||
properties.parse(section)
|
||||
timezone_name = properties.get_device_timezone()
|
||||
timezone = None
|
||||
if timezone_name:
|
||||
try:
|
||||
timezone = ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
self.log.warning("Unknown device timezone %s", timezone_name)
|
||||
|
||||
self.results = []
|
||||
for file in filesystem_files:
|
||||
# A zip entry keeps the device's wall clock, read in the device's
|
||||
# timezone when the command found one (see CmdAndroidCheckBugreport);
|
||||
# an unpacked bugreport's mtime is read as a UTC instant.
|
||||
# Only the modification time is available in the zip file metadata.
|
||||
# The timezone is the local timezone of the machine the phone.
|
||||
modification_time = self._get_file_modification_time(file)
|
||||
utc_time = None
|
||||
if timezone is not None:
|
||||
utc_time = convert_datetime_to_iso(
|
||||
modification_time.replace(tzinfo=timezone).astimezone(
|
||||
datetime.timezone.utc
|
||||
)
|
||||
)
|
||||
self.results.append(
|
||||
{
|
||||
"path": file,
|
||||
"modified_time": convert_datetime_to_iso(
|
||||
modification_time.replace(tzinfo=None)
|
||||
),
|
||||
"modified_time_utc": (
|
||||
convert_datetime_to_iso(modification_time)
|
||||
if modification_time.tzinfo
|
||||
else None
|
||||
),
|
||||
"timezone": (
|
||||
self.module_options.get("device_timezone")
|
||||
if self.zip_archive
|
||||
else "UTC"
|
||||
),
|
||||
"modified_time": convert_datetime_to_iso(modification_time),
|
||||
"modified_time_utc": utc_time,
|
||||
"timezone": timezone_name,
|
||||
"timestamp_source": (
|
||||
"zip_metadata" if self.zip_archive else "filesystem_metadata"
|
||||
),
|
||||
|
||||
@@ -56,10 +56,6 @@ class CmdCheckIOCS(Command):
|
||||
if entry not in all_modules:
|
||||
all_modules.append(entry)
|
||||
|
||||
# Read the indicators once, so that a missing indicators file is
|
||||
# reported even when no stored result matches a module.
|
||||
iocs = self.iocs
|
||||
|
||||
log.info("Checking stored results against provided indicators...")
|
||||
|
||||
total_detections = 0
|
||||
@@ -87,8 +83,8 @@ class CmdCheckIOCS(Command):
|
||||
log.warning("No result from this module, skipping it")
|
||||
continue
|
||||
|
||||
if iocs.total_ioc_count > 0:
|
||||
m.indicators = iocs
|
||||
if self.iocs.total_ioc_count > 0:
|
||||
m.indicators = self.iocs
|
||||
m.indicators.log = m.log
|
||||
|
||||
try:
|
||||
|
||||
+10
-24
@@ -8,7 +8,6 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from functools import cached_property
|
||||
from heapq import heappop, heappush
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -85,18 +84,18 @@ class Command:
|
||||
self.timeline: ModuleTimeline = []
|
||||
self.url_results: list[URLResult] = []
|
||||
|
||||
# Load IOCs
|
||||
self._create_storage()
|
||||
self._setup_logging()
|
||||
|
||||
if iocs is not None:
|
||||
self.iocs = iocs
|
||||
else:
|
||||
self.iocs = Indicators(self.log)
|
||||
self.iocs.load_indicators_files(self.ioc_files)
|
||||
|
||||
self.alertstore = AlertStore()
|
||||
|
||||
@cached_property
|
||||
def iocs(self) -> Indicators:
|
||||
"""Load indicators on first use. Nested commands share their parent's."""
|
||||
iocs = Indicators(self.log)
|
||||
iocs.load_indicators_files(self.ioc_files)
|
||||
return iocs
|
||||
|
||||
def _create_storage(self) -> None:
|
||||
if self.results_path and not os.path.exists(self.results_path):
|
||||
try:
|
||||
@@ -711,30 +710,17 @@ class Command:
|
||||
return ordered
|
||||
|
||||
def run(self) -> None:
|
||||
# The output folder and its command.log exist for a run, so that
|
||||
# listing modules or rejecting a target leaves nothing behind.
|
||||
# Resolving the module list can warn, so the log comes first.
|
||||
self._create_storage()
|
||||
self._setup_logging()
|
||||
|
||||
ordered_modules = self._ordered_modules()
|
||||
if ordered_modules is None:
|
||||
return
|
||||
|
||||
# Read the indicators once the run is certain to happen, before
|
||||
# init() does any work on the target, so that a missing indicators
|
||||
# file is reported first and every module gets the same object.
|
||||
iocs = self.iocs
|
||||
self._log_loaded_modules(ordered_modules)
|
||||
|
||||
# Commands announce their target from init(), so it goes before the
|
||||
# module list.
|
||||
try:
|
||||
self.init()
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
self._log_loaded_modules(ordered_modules)
|
||||
|
||||
executed_by_type: dict[type[MVTModule], MVTModule] = {}
|
||||
for module in ordered_modules:
|
||||
|
||||
@@ -754,8 +740,8 @@ class Command:
|
||||
for dependency, resolved in self._module_dependencies(module)
|
||||
}
|
||||
|
||||
if iocs.total_ioc_count:
|
||||
m.indicators = iocs
|
||||
if self.iocs.total_ioc_count:
|
||||
m.indicators = self.iocs
|
||||
m.indicators.log = m.log
|
||||
|
||||
if self.serial:
|
||||
|
||||
@@ -12,9 +12,7 @@ from pydantic_settings import (
|
||||
YamlConfigSettingsSource,
|
||||
)
|
||||
|
||||
# MVT_CONFIG_FOLDER in the environment relocates the settings file, so that
|
||||
# a test run or a scripted install never touches the user's own.
|
||||
MVT_CONFIG_FOLDER = os.environ.get("MVT_CONFIG_FOLDER") or user_config_dir("mvt")
|
||||
MVT_CONFIG_FOLDER = user_config_dir("mvt")
|
||||
MVT_CONFIG_PATH = os.path.join(MVT_CONFIG_FOLDER, "config.yaml")
|
||||
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ from appdirs import user_data_dir
|
||||
from .config import settings
|
||||
from .url import URL
|
||||
|
||||
# MVT_DATA_FOLDER in the environment relocates the downloaded indicators and
|
||||
# the update-check state kept next to them.
|
||||
MVT_DATA_FOLDER = os.environ.get("MVT_DATA_FOLDER") or user_data_dir("mvt")
|
||||
MVT_DATA_FOLDER = user_data_dir("mvt")
|
||||
MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,9 +71,7 @@ class Indicators:
|
||||
if os.path.isfile(path) and path.lower().endswith(".stix2"):
|
||||
self.parse_stix2(path)
|
||||
elif os.path.isdir(path):
|
||||
for file in glob.glob(
|
||||
os.path.join(path, "**", "*.stix2"), recursive=True
|
||||
):
|
||||
for file in glob.glob(os.path.join(path, "**", "*.stix2"), recursive=True):
|
||||
self.parse_stix2(file)
|
||||
else:
|
||||
self.log.error(
|
||||
@@ -522,7 +518,9 @@ class Indicators:
|
||||
the original URL order.
|
||||
"""
|
||||
batches = [list(urls) if urls else [] for urls in url_batches]
|
||||
unique_urls = list(dict.fromkeys(url for urls in batches for url in urls))
|
||||
unique_urls = list(
|
||||
dict.fromkeys(url for urls in batches for url in urls)
|
||||
)
|
||||
|
||||
if not unique_urls:
|
||||
return [None] * len(batches)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import base64
|
||||
import cProfile
|
||||
import datetime
|
||||
import hashlib
|
||||
@@ -184,6 +185,29 @@ def keys_bytes_to_string(obj: Any) -> Any:
|
||||
return new_obj
|
||||
|
||||
|
||||
def sanitize_json_data(obj: Any) -> Any:
|
||||
"""Recursively convert values to JSON-compatible representations.
|
||||
|
||||
SQLite and plist records can contain binary and date values nested inside
|
||||
dictionaries. Preserve binary data as base64 rather than discarding it or
|
||||
relying on a lossy text decoding during JSON serialization.
|
||||
"""
|
||||
if isinstance(obj, datetime.datetime):
|
||||
return convert_datetime_to_iso(obj)
|
||||
if isinstance(obj, datetime.date):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, bytes):
|
||||
return base64.b64encode(obj).decode("ascii")
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
sanitize_json_data(key): sanitize_json_data(value)
|
||||
for key, value in obj.items()
|
||||
}
|
||||
if isinstance(obj, (tuple, list, set)):
|
||||
return [sanitize_json_data(value) for value in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def get_sha256_from_file_path(file_path: str) -> str:
|
||||
"""Calculate the SHA256 hash of a file from a file path.
|
||||
|
||||
|
||||
+10
-8
@@ -318,6 +318,8 @@ def check_backup(
|
||||
if not cmd.resolve_backup_path():
|
||||
ctx.exit(1)
|
||||
|
||||
log.info("Checking iTunes backup located at: %s", cmd.target_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
@@ -384,6 +386,8 @@ def check_fs(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking iOS filesystem located at: %s", dump_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
@@ -445,20 +449,18 @@ def check_sysdiagnose(
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
# MVT's own module only records the device details; the checks come from
|
||||
# custom modules, so a run without any must not look like a clean analysis.
|
||||
if all(module in cmd.modules for module in cmd._available_modules()):
|
||||
log.warning(
|
||||
"No forensic sysdiagnose modules have been loaded: MVT's own "
|
||||
"SysdiagnoseInfo module only records the device details. Install a "
|
||||
"module package or load a module that declares supported_commands = "
|
||||
'(("ios", "check-sysdiagnose"),) to check the sysdiagnose.'
|
||||
if not cmd._available_modules():
|
||||
raise click.ClickException(
|
||||
"No custom modules support mvt-ios check-sysdiagnose. "
|
||||
"Load a module that declares supported_commands = "
|
||||
"((\"ios\", \"check-sysdiagnose\"),)."
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path)
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
@@ -59,9 +59,6 @@ class CmdIOSCheckBackup(Command):
|
||||
self.name = "check-backup"
|
||||
self.modules = BACKUP_MODULES + MIXED_MODULES
|
||||
|
||||
def init(self) -> None:
|
||||
self.log.info("Checking iTunes backup located at: %s", self.target_path)
|
||||
|
||||
def resolve_backup_path(self) -> bool:
|
||||
target_path = getattr(self, "target_path", None)
|
||||
if not isinstance(target_path, str) or not target_path:
|
||||
|
||||
@@ -51,8 +51,5 @@ class CmdIOSCheckFS(Command):
|
||||
self.name = "check-fs"
|
||||
self.modules = FS_MODULES + MIXED_MODULES
|
||||
|
||||
def init(self) -> None:
|
||||
self.log.info("Checking iOS filesystem located at: %s", self.target_path)
|
||||
|
||||
def module_init(self, module):
|
||||
module.is_fs_dump = True
|
||||
@@ -7,9 +7,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import zlib
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Optional
|
||||
@@ -18,8 +16,6 @@ from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.sysdiagnose import SYSDIAGNOSE_MODULES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,7 +52,6 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
)
|
||||
self.platform = "ios"
|
||||
self.name = "check-sysdiagnose"
|
||||
self.modules = SYSDIAGNOSE_MODULES
|
||||
self.sysdiagnose_format: Optional[str] = None
|
||||
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
|
||||
self.sysdiagnose_files: list[str] = []
|
||||
@@ -81,15 +76,11 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
if not self.target_path:
|
||||
raise ValueError("A sysdiagnose path is required")
|
||||
|
||||
self.log.info("Checking iOS sysdiagnose at path: %s", self.target_path)
|
||||
|
||||
if os.path.isdir(self.target_path):
|
||||
self.sysdiagnose_format = "dir"
|
||||
parent_path = Path(self.target_path).absolute().parent
|
||||
for root, _, filenames in os.walk(self.target_path):
|
||||
for filename in filenames:
|
||||
if filename.startswith("._"):
|
||||
continue
|
||||
absolute_path = os.path.join(root, filename)
|
||||
file_path = os.path.relpath(absolute_path, parent_path)
|
||||
self.sysdiagnose_files.append(file_path)
|
||||
@@ -103,19 +94,8 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
|
||||
self.log.info("Parsing sysdiagnose archive. This might take a while...")
|
||||
self.sysdiagnose_format = "tar"
|
||||
try:
|
||||
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
|
||||
self._extract_sysdiagnose_archive()
|
||||
except (tarfile.ReadError, EOFError, zlib.error, OSError) as exc:
|
||||
# A truncated archive ends in EOFError from gzip, which Click would
|
||||
# otherwise report as a bare "Aborted!" with no reason.
|
||||
self.log.critical(
|
||||
"Unable to read the sysdiagnose archive %s: %s. "
|
||||
"The file may be truncated or not a gzip-compressed tarball.",
|
||||
self.target_path,
|
||||
exc,
|
||||
)
|
||||
sys.exit(1)
|
||||
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
|
||||
self._extract_sysdiagnose_archive()
|
||||
|
||||
def _extract_sysdiagnose_archive(self) -> None:
|
||||
archive = self.sysdiagnose_archive
|
||||
@@ -139,11 +119,6 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
|
||||
if not member_path.parts:
|
||||
continue
|
||||
# AppleDouble sidecars (._name) carry a file's extended attributes,
|
||||
# not sysdiagnose content. Device archives hold hundreds of them;
|
||||
# bsdtar hides them from listings, tarfile does not.
|
||||
if member_path.name.startswith("._"):
|
||||
continue
|
||||
archive_roots.add(member_path.parts[0])
|
||||
|
||||
if member.isdir():
|
||||
|
||||
@@ -16,8 +16,7 @@ from mvt.common.module import MVTModule
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
from .modules.fs import FS_MODULES
|
||||
from .modules.mixed import MIXED_MODULES
|
||||
from .modules.sysdiagnose import SYSDIAGNOSE_MODULES
|
||||
|
||||
IOS_CHECK_IOCS_MODULES: list[type[MVTModule]] = (
|
||||
BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES
|
||||
BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
)
|
||||
@@ -1271,13 +1271,5 @@
|
||||
{
|
||||
"version": "26.6.1",
|
||||
"build": "23G83"
|
||||
},
|
||||
{
|
||||
"version": "26.6.2",
|
||||
"build": "23G90"
|
||||
},
|
||||
{
|
||||
"version": "27.0",
|
||||
"build": "24A437"
|
||||
}
|
||||
]
|
||||
@@ -52,7 +52,7 @@ class MVTEncryptedBackup(EncryptedBackup):
|
||||
|
||||
NOTE: This subclass relies on internal APIs of iphone_backup_decrypt
|
||||
(specifically _read_and_unlock_keybag, _keybag, and the Keybag class
|
||||
internals). Pinned to iphone_backup_decrypt==0.10.0.
|
||||
internals). Pinned to iphone_backup_decrypt==0.9.0.
|
||||
"""
|
||||
|
||||
def __init__(self, *, backup_directory, passphrase=None, derived_key=None):
|
||||
|
||||
@@ -11,7 +11,7 @@ from mvt.common.module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import convert_mactime_to_iso
|
||||
from mvt.common.utils import convert_mactime_to_iso, sanitize_json_data
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -90,54 +90,63 @@ class Calendar(IOSExtraction):
|
||||
"""
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
CalendarItem.ROWID as "id",
|
||||
CalendarItem.summary as "summary",
|
||||
CalendarItem.description as "description",
|
||||
CalendarItem.start_date as "start_date",
|
||||
CalendarItem.end_date as "end_date",
|
||||
CalendarItem.all_day as "all_day",
|
||||
CalendarItem.calendar_id as "calendar_id",
|
||||
CalendarItem.organizer_id as "organizer_id",
|
||||
CalendarItem.url as "url",
|
||||
CalendarItem.last_modified as "last_modified",
|
||||
CalendarItem.external_id as "external_id",
|
||||
CalendarItem.external_mod_tag as "external_mod_tag",
|
||||
CalendarItem.unique_identifier as "unique_identifier",
|
||||
CalendarItem.hidden as "hidden",
|
||||
CalendarItem.UUID as "uuid",
|
||||
CalendarItem.creation_date as "creation_date",
|
||||
CalendarItem.action as "action",
|
||||
CalendarItem.created_by_id as "created_by_id",
|
||||
Participant.UUID as "participant_uuid",
|
||||
Participant.email as "participant_email",
|
||||
Participant.phone_number as "participant_phone",
|
||||
Participant.comment as "participant_comment",
|
||||
Participant.last_modified as "participant_last_modified"
|
||||
FROM CalendarItem
|
||||
LEFT JOIN Participant ON Participant.ROWID = CalendarItem.organizer_id;
|
||||
"""
|
||||
)
|
||||
|
||||
names = [description[0] for description in cur.description]
|
||||
for item in cur:
|
||||
entry = {}
|
||||
for index, value in enumerate(item):
|
||||
if names[index] in self.timestamps:
|
||||
if value is None or isinstance(value, str):
|
||||
entry[names[index]] = value
|
||||
else:
|
||||
entry[names[index]] = convert_mactime_to_iso(value)
|
||||
else:
|
||||
entry[names[index]] = value
|
||||
|
||||
self.results.append(entry)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
try:
|
||||
cur.execute("PRAGMA table_info(CalendarItem);")
|
||||
calendar_columns = [row[1] for row in cur]
|
||||
cur.execute("PRAGMA table_info(Participant);")
|
||||
participant_columns = [row[1] for row in cur]
|
||||
selected = [
|
||||
f'c."{column}" AS "calendar_{column}"' for column in calendar_columns
|
||||
] + [
|
||||
f'p."{column}" AS "participant_{column}"'
|
||||
for column in participant_columns
|
||||
]
|
||||
cur.execute(
|
||||
f"SELECT {', '.join(selected)} FROM CalendarItem c "
|
||||
"LEFT JOIN Participant p ON p.ROWID = c.organizer_id;"
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
for row in cur:
|
||||
raw = sanitize_json_data(dict(zip(names, row)))
|
||||
entry = {
|
||||
"id": raw.get("calendar_ROWID"),
|
||||
"summary": raw.get("calendar_summary"),
|
||||
"description": raw.get("calendar_description"),
|
||||
"all_day": raw.get("calendar_all_day"),
|
||||
"calendar_id": raw.get("calendar_calendar_id"),
|
||||
"organizer_id": raw.get("calendar_organizer_id"),
|
||||
"url": raw.get("calendar_url"),
|
||||
"external_id": raw.get("calendar_external_id"),
|
||||
"external_mod_tag": raw.get("calendar_external_mod_tag"),
|
||||
"unique_identifier": raw.get("calendar_unique_identifier"),
|
||||
"hidden": raw.get("calendar_hidden"),
|
||||
"uuid": raw.get("calendar_UUID"),
|
||||
"action": raw.get("calendar_action"),
|
||||
"created_by_id": raw.get("calendar_created_by_id"),
|
||||
"participant_uuid": raw.get("participant_UUID"),
|
||||
"participant_email": raw.get("participant_email"),
|
||||
"participant_phone": raw.get("participant_phone_number"),
|
||||
"participant_comment": raw.get("participant_comment"),
|
||||
"record": raw,
|
||||
}
|
||||
timestamp_sources = {
|
||||
"start_date": "calendar_start_date",
|
||||
"end_date": "calendar_end_date",
|
||||
"last_modified": "calendar_last_modified",
|
||||
"creation_date": "calendar_creation_date",
|
||||
"participant_last_modified": "participant_last_modified",
|
||||
}
|
||||
for output, source in timestamp_sources.items():
|
||||
value = raw.get(source)
|
||||
entry[output] = (
|
||||
convert_mactime_to_iso(value)
|
||||
if value is not None and not isinstance(value, str)
|
||||
else value
|
||||
)
|
||||
self.results.append(entry)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def run(self) -> None:
|
||||
self._find_ios_database(
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from mvt.common.utils import convert_mactime_to_iso
|
||||
from mvt.common.utils import convert_mactime_to_iso, sanitize_json_data
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -57,31 +57,30 @@ class Calls(IOSExtraction):
|
||||
return
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ZDATE, ZDURATION, ZLOCATION, ZADDRESS, ZSERVICE_PROVIDER
|
||||
FROM ZCALLRECORD;
|
||||
"""
|
||||
)
|
||||
# names = [description[0] for description in cur.description]
|
||||
|
||||
for row in cur:
|
||||
self.results.append(
|
||||
{
|
||||
"isodate": convert_mactime_to_iso(row[0]),
|
||||
"duration": row[1],
|
||||
"location": row[2],
|
||||
"number": (
|
||||
row[3].decode("utf-8")
|
||||
if isinstance(row[3], bytes)
|
||||
else row[3]
|
||||
),
|
||||
"provider": row[4],
|
||||
}
|
||||
)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
try:
|
||||
cur.execute("SELECT * FROM ZCALLRECORD;")
|
||||
names = [description[0] for description in cur.description]
|
||||
for row in cur:
|
||||
original = dict(zip(names, row))
|
||||
address = original.get("ZADDRESS")
|
||||
if isinstance(address, bytes):
|
||||
address = address.decode("utf-8", errors="replace")
|
||||
raw = sanitize_json_data(original)
|
||||
date = raw.get("ZDATE")
|
||||
self.results.append(
|
||||
{
|
||||
"isodate": (
|
||||
convert_mactime_to_iso(date) if date is not None else ""
|
||||
),
|
||||
"duration": raw.get("ZDURATION"),
|
||||
"location": raw.get("ZLOCATION"),
|
||||
"number": address,
|
||||
"provider": raw.get("ZSERVICE_PROVIDER"),
|
||||
"call": raw,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
self.log.info("Extracted a total of %d calls", len(self.results))
|
||||
@@ -8,6 +8,7 @@ import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.utils import sanitize_json_data
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -51,28 +52,45 @@ class Contacts(IOSExtraction):
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
multi.value, person.first, person.middle, person.last,
|
||||
person.organization
|
||||
FROM ABPerson person, ABMultiValue multi
|
||||
WHERE person.rowid = multi.record_id and multi.value not null
|
||||
ORDER by person.rowid ASC;
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.info("Error while reading the contact table: %s", e)
|
||||
return None
|
||||
cur.execute("PRAGMA table_info(ABPerson);")
|
||||
person_columns = [row[1] for row in cur]
|
||||
cur.execute("PRAGMA table_info(ABMultiValue);")
|
||||
multivalue_columns = [row[1] for row in cur]
|
||||
if not person_columns or not multivalue_columns:
|
||||
return
|
||||
|
||||
select_columns = [
|
||||
f'p."{column}" AS "person_{column}"' for column in person_columns
|
||||
] + [
|
||||
f'm."{column}" AS "multivalue_{column}"'
|
||||
for column in multivalue_columns
|
||||
]
|
||||
cur.execute(
|
||||
f"SELECT {', '.join(select_columns)} "
|
||||
"FROM ABPerson p LEFT JOIN ABMultiValue m "
|
||||
"ON p.ROWID = m.record_id ORDER BY p.ROWID, m.ROWID;"
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
for row in cur:
|
||||
new_contact = {}
|
||||
for index, value in enumerate(row):
|
||||
new_contact[names[index]] = value
|
||||
|
||||
self.results.append(new_contact)
|
||||
raw = sanitize_json_data(dict(zip(names, row)))
|
||||
self.results.append(
|
||||
{
|
||||
"value": raw.get("multivalue_value"),
|
||||
"first": raw.get("person_First"),
|
||||
"middle": raw.get("person_Middle"),
|
||||
"last": raw.get("person_Last"),
|
||||
"organization": raw.get("person_Organization"),
|
||||
# Keep the capitalization returned by the previous
|
||||
# query for consumers of existing JSON output.
|
||||
"First": raw.get("person_First"),
|
||||
"Middle": raw.get("person_Middle"),
|
||||
"Last": raw.get("person_Last"),
|
||||
"Organization": raw.get("person_Organization"),
|
||||
"contact": raw,
|
||||
}
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
self.log.info("Error while reading the contact table: %s", exc)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@@ -14,7 +14,7 @@ from mvt.common.module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import convert_mactime_to_iso
|
||||
from mvt.common.utils import convert_mactime_to_iso, sanitize_json_data
|
||||
|
||||
from ..base import IOSExtraction
|
||||
from .whatsapp_contacts import WhatsappContacts
|
||||
@@ -547,4 +547,22 @@ class InteractionC(IOSExtraction):
|
||||
|
||||
self._postprocess_results()
|
||||
|
||||
# Preserve every column from the underlying interaction row. The
|
||||
# joined compatibility queries above intentionally expose a stable
|
||||
# result schema but otherwise discard useful forensic fields.
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT * FROM ZINTERACTIONS;")
|
||||
names = [description[0] for description in cur.description]
|
||||
interactions = {}
|
||||
for row in cur:
|
||||
raw = sanitize_json_data(dict(zip(names, row)))
|
||||
interactions[raw.get("Z_PK")] = raw
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
for result in self.results:
|
||||
result["interaction"] = interactions.get(result.get("table_id"), {})
|
||||
|
||||
self.log.info("Extracted a total of %d InteractionC events", len(self.results))
|
||||
@@ -7,6 +7,8 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.utils import convert_mactime_to_iso, sanitize_json_data
|
||||
|
||||
from ..net_base import NetBase
|
||||
|
||||
DATAUSAGE_BACKUP_IDS = [
|
||||
@@ -42,6 +44,77 @@ class Datausage(NetBase):
|
||||
results=results,
|
||||
)
|
||||
|
||||
def _extract_net_data(self):
|
||||
assert self.file_path is not None
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("PRAGMA table_info(ZPROCESS);")
|
||||
process_columns = [row[1] for row in cur]
|
||||
cur.execute("PRAGMA table_info(ZLIVEUSAGE);")
|
||||
live_columns = [row[1] for row in cur]
|
||||
selected = [
|
||||
f'p."{column}" AS "process_{column}"' for column in process_columns
|
||||
] + [f'l."{column}" AS "live_{column}"' for column in live_columns]
|
||||
cur.execute(
|
||||
f"SELECT {', '.join(selected)} FROM ZLIVEUSAGE l "
|
||||
"LEFT JOIN ZPROCESS p ON l.ZHASPROCESS = p.Z_PK;"
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
rows = [dict(zip(names, row)) for row in cur]
|
||||
|
||||
process_only = [
|
||||
f'p."{column}" AS "process_{column}"' for column in process_columns
|
||||
] + [f'NULL AS "live_{column}"' for column in live_columns]
|
||||
cur.execute(
|
||||
f"SELECT {', '.join(process_only)} FROM ZPROCESS p "
|
||||
"WHERE NOT EXISTS ("
|
||||
"SELECT 1 FROM ZLIVEUSAGE l WHERE l.ZHASPROCESS = p.Z_PK);"
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
rows.extend(dict(zip(names, row)) for row in cur)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
for raw_row in rows:
|
||||
first_timestamp = raw_row.get("process_ZFIRSTTIMESTAMP")
|
||||
process_timestamp = raw_row.get("process_ZTIMESTAMP")
|
||||
live_timestamp = raw_row.get("live_ZTIMESTAMP")
|
||||
first_isodate = (
|
||||
convert_mactime_to_iso(first_timestamp)
|
||||
if first_timestamp
|
||||
else first_timestamp
|
||||
)
|
||||
isodate = (
|
||||
convert_mactime_to_iso(process_timestamp)
|
||||
if process_timestamp
|
||||
else process_timestamp
|
||||
)
|
||||
live_isodate = (
|
||||
convert_mactime_to_iso(live_timestamp)
|
||||
if live_timestamp
|
||||
else first_isodate
|
||||
)
|
||||
self.results.append(
|
||||
{
|
||||
"first_isodate": first_isodate,
|
||||
"isodate": isodate,
|
||||
"proc_name": raw_row.get("process_ZPROCNAME"),
|
||||
"bundle_id": raw_row.get("process_ZBUNDLENAME"),
|
||||
"proc_id": raw_row.get("process_Z_PK"),
|
||||
"wifi_in": raw_row.get("live_ZWIFIIN"),
|
||||
"wifi_out": raw_row.get("live_ZWIFIOUT"),
|
||||
"wwan_in": raw_row.get("live_ZWWANIN"),
|
||||
"wwan_out": raw_row.get("live_ZWWANOUT"),
|
||||
"live_id": raw_row.get("live_Z_PK"),
|
||||
"live_proc_id": raw_row.get("live_ZHASPROCESS"),
|
||||
"live_isodate": live_isodate,
|
||||
"record": sanitize_json_data(raw_row),
|
||||
}
|
||||
)
|
||||
self.log.info("Extracted information on %d processes", len(self.results))
|
||||
|
||||
def run(self) -> None:
|
||||
self._find_ios_database(
|
||||
backup_ids=DATAUSAGE_BACKUP_IDS, root_paths=DATAUSAGE_ROOT_PATHS
|
||||
|
||||
@@ -15,7 +15,11 @@ from mvt.common.module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import convert_mactime_to_iso, keys_bytes_to_string
|
||||
from mvt.common.utils import (
|
||||
convert_mactime_to_iso,
|
||||
keys_bytes_to_string,
|
||||
sanitize_json_data,
|
||||
)
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -94,85 +98,76 @@ class SafariBrowserState(IOSExtraction):
|
||||
def _process_browser_state_db(self, db_path):
|
||||
self._recover_sqlite_db_if_needed(db_path)
|
||||
conn = self._open_sqlite_db(db_path)
|
||||
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
tabs.title,
|
||||
tabs.url,
|
||||
tabs.user_visible_url,
|
||||
tabs.last_viewed_time,
|
||||
tab_sessions.session_data
|
||||
SELECT tabs.*, tab_sessions.session_data AS session_data
|
||||
FROM tabs
|
||||
JOIN tab_sessions ON tabs.uuid = tab_sessions.tab_uuid
|
||||
LEFT JOIN tab_sessions ON tabs.uuid = tab_sessions.tab_uuid
|
||||
ORDER BY tabs.last_viewed_time;
|
||||
"""
|
||||
"""
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
except sqlite3.OperationalError:
|
||||
# Old version iOS <12 likely
|
||||
# Older databases store session_data directly in tabs.
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
title, url, user_visible_url, last_viewed_time, session_data
|
||||
FROM tabs
|
||||
ORDER BY last_viewed_time;
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.error(f"Error executing query: {e}")
|
||||
cur.execute("SELECT * FROM tabs ORDER BY last_viewed_time;")
|
||||
names = [description[0] for description in cur.description]
|
||||
except sqlite3.OperationalError as exc:
|
||||
self.log.error("Error executing query: %s", exc)
|
||||
return
|
||||
|
||||
for row in cur:
|
||||
raw = dict(zip(names, row))
|
||||
session_entries = []
|
||||
|
||||
if row[4]:
|
||||
session_blob = raw.get("session_data")
|
||||
if session_blob:
|
||||
# Skip a 4 byte header before the plist content.
|
||||
session_plist = row[4][4:]
|
||||
session_plist = session_blob[4:]
|
||||
session_data = {}
|
||||
try:
|
||||
session_data = plistlib.load(io.BytesIO(session_plist))
|
||||
session_data = keys_bytes_to_string(session_data)
|
||||
except plistlib.InvalidFileException:
|
||||
except (plistlib.InvalidFileException, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if "SessionHistoryEntries" in session_data.get("SessionHistory", {}):
|
||||
for session_entry in session_data["SessionHistory"].get(
|
||||
"SessionHistoryEntries"
|
||||
):
|
||||
self._session_history_count += 1
|
||||
|
||||
data_length = 0
|
||||
if "SessionHistoryEntryData" in session_entry:
|
||||
data_length = len(
|
||||
session_entry.get("SessionHistoryEntryData")
|
||||
)
|
||||
|
||||
session_entries.append(
|
||||
{
|
||||
"entry_title": session_entry.get(
|
||||
"SessionHistoryEntryOriginalURL"
|
||||
),
|
||||
"entry_url": session_entry.get(
|
||||
"SessionHistoryEntryURL"
|
||||
),
|
||||
"data_length": data_length,
|
||||
}
|
||||
)
|
||||
for session_entry in session_data.get("SessionHistory", {}).get(
|
||||
"SessionHistoryEntries", []
|
||||
):
|
||||
self._session_history_count += 1
|
||||
entry_data = session_entry.get("SessionHistoryEntryData")
|
||||
session_entries.append(
|
||||
{
|
||||
"entry_title": session_entry.get(
|
||||
"SessionHistoryEntryOriginalURL"
|
||||
),
|
||||
"entry_url": session_entry.get(
|
||||
"SessionHistoryEntryURL"
|
||||
),
|
||||
"data_length": (
|
||||
len(entry_data) if entry_data is not None else 0
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
last_viewed = raw.get("last_viewed_time")
|
||||
self.results.append(
|
||||
{
|
||||
"tab_title": row[0],
|
||||
"tab_url": row[1],
|
||||
"tab_visible_url": row[2],
|
||||
"last_viewed_timestamp": convert_mactime_to_iso(row[3]),
|
||||
"tab_title": raw.get("title"),
|
||||
"tab_url": raw.get("url"),
|
||||
"tab_visible_url": raw.get("user_visible_url"),
|
||||
"last_viewed_timestamp": (
|
||||
convert_mactime_to_iso(last_viewed)
|
||||
if last_viewed is not None
|
||||
else ""
|
||||
),
|
||||
"session_data": session_entries,
|
||||
"safari_browser_state_db": os.path.relpath(
|
||||
db_path, self.target_path
|
||||
),
|
||||
"tab": sanitize_json_data(raw),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -13,7 +13,11 @@ from mvt.common.module_types import (
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.url import URL
|
||||
from mvt.common.utils import convert_mactime_to_datetime, convert_mactime_to_iso
|
||||
from mvt.common.utils import (
|
||||
convert_mactime_to_datetime,
|
||||
convert_mactime_to_iso,
|
||||
sanitize_json_data,
|
||||
)
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -133,39 +137,41 @@ class SafariHistory(IOSExtraction):
|
||||
self._recover_sqlite_db_if_needed(history_path)
|
||||
conn = self._open_sqlite_db(history_path)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
history_items.id,
|
||||
history_items.url,
|
||||
history_visits.id,
|
||||
history_visits.visit_time,
|
||||
history_visits.redirect_source,
|
||||
history_visits.redirect_destination
|
||||
FROM history_items
|
||||
JOIN history_visits ON history_visits.history_item = history_items.id
|
||||
ORDER BY history_visits.visit_time;
|
||||
"""
|
||||
)
|
||||
|
||||
for row in cur:
|
||||
self.results.append(
|
||||
{
|
||||
"id": row[0],
|
||||
"url": row[1],
|
||||
"visit_id": row[2],
|
||||
"timestamp": row[3],
|
||||
"isodate": convert_mactime_to_iso(row[3]),
|
||||
"redirect_source": row[4],
|
||||
"redirect_destination": row[5],
|
||||
"safari_history_db": os.path.relpath(
|
||||
history_path, self.target_path
|
||||
),
|
||||
}
|
||||
try:
|
||||
cur.execute("PRAGMA table_info(history_items);")
|
||||
item_columns = [row[1] for row in cur]
|
||||
cur.execute("PRAGMA table_info(history_visits);")
|
||||
visit_columns = [row[1] for row in cur]
|
||||
selected = [
|
||||
f'i."{column}" AS "item_{column}"' for column in item_columns
|
||||
] + [f'v."{column}" AS "visit_{column}"' for column in visit_columns]
|
||||
cur.execute(
|
||||
f"SELECT {', '.join(selected)} FROM history_items i "
|
||||
"JOIN history_visits v ON v.history_item = i.id "
|
||||
"ORDER BY v.visit_time;"
|
||||
)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
names = [description[0] for description in cur.description]
|
||||
for row in cur:
|
||||
raw = sanitize_json_data(dict(zip(names, row)))
|
||||
timestamp = raw.get("visit_visit_time")
|
||||
self.results.append(
|
||||
{
|
||||
"id": raw.get("item_id"),
|
||||
"url": raw.get("item_url"),
|
||||
"visit_id": raw.get("visit_id"),
|
||||
"timestamp": timestamp,
|
||||
"isodate": convert_mactime_to_iso(timestamp),
|
||||
"redirect_source": raw.get("visit_redirect_source"),
|
||||
"redirect_destination": raw.get("visit_redirect_destination"),
|
||||
"safari_history_db": os.path.relpath(
|
||||
history_path, self.target_path
|
||||
),
|
||||
"record": raw,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def run(self) -> None:
|
||||
if self.is_backup:
|
||||
|
||||
@@ -12,7 +12,7 @@ from mvt.common.module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import convert_unix_to_iso
|
||||
from mvt.common.utils import convert_unix_to_iso, sanitize_json_data
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -103,126 +103,49 @@ class TCC(IOSExtraction):
|
||||
def process_db(self, file_path):
|
||||
conn = self._open_sqlite_db(file_path)
|
||||
cur = conn.cursor()
|
||||
db_version = "v3"
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
service, client, client_type, auth_value,
|
||||
auth_reason, last_modified
|
||||
FROM access;"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
# v2 version
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
service, client, client_type, allowed,
|
||||
prompt_count, last_modified
|
||||
FROM access;"""
|
||||
cur.execute("SELECT * FROM access;")
|
||||
except sqlite3.OperationalError as exc:
|
||||
self.log.error("Error parsing TCC database: %s", exc)
|
||||
return
|
||||
|
||||
names = [description[0] for description in cur.description]
|
||||
for row in cur:
|
||||
record = sanitize_json_data(dict(zip(names, row)))
|
||||
client_type = record.get("client_type")
|
||||
record["client_type_value"] = client_type
|
||||
record["client_type"] = (
|
||||
"bundle_id" if client_type == 0 else "absolute_path"
|
||||
)
|
||||
db_version = "v2"
|
||||
except sqlite3.OperationalError:
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
service, client, client_type, allowed,
|
||||
prompt_count
|
||||
FROM access;"""
|
||||
)
|
||||
db_version = "v1"
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.error(f"Error parsing TCC database: {e}")
|
||||
|
||||
for row in cur:
|
||||
service = row[0]
|
||||
client = row[1]
|
||||
client_type = row[2]
|
||||
client_type_desc = "bundle_id" if client_type == 0 else "absolute_path"
|
||||
if db_version == "v3":
|
||||
auth_value = row[3]
|
||||
auth_value_desc = AUTH_VALUES.get(auth_value, "")
|
||||
auth_reason = row[4]
|
||||
auth_reason_desc = AUTH_REASONS.get(auth_reason, "unknown")
|
||||
last_modified = convert_unix_to_iso(row[5])
|
||||
|
||||
if service in ["kTCCServiceMicrophone", "kTCCServiceCamera"]:
|
||||
device = (
|
||||
"microphone" if service == "kTCCServiceMicrophone" else "camera"
|
||||
if "auth_value" in record:
|
||||
auth_value = record["auth_value"]
|
||||
record["auth_value_value"] = auth_value
|
||||
record["auth_value"] = AUTH_VALUES.get(auth_value, "unknown")
|
||||
auth_reason = record.get("auth_reason")
|
||||
record["auth_reason_desc"] = AUTH_REASONS.get(
|
||||
auth_reason, "unknown"
|
||||
)
|
||||
self.log.info(
|
||||
'Found client "%s" with access %s to %s on %s by %s',
|
||||
client,
|
||||
auth_value_desc,
|
||||
device,
|
||||
last_modified,
|
||||
auth_reason_desc,
|
||||
elif "allowed" in record:
|
||||
allowed = record["allowed"]
|
||||
record["allowed_value"] = AUTH_VALUE_OLD.get(allowed, "unknown")
|
||||
|
||||
if record.get("last_modified") is not None:
|
||||
record["last_modified_value"] = record["last_modified"]
|
||||
record["last_modified"] = convert_unix_to_iso(
|
||||
record["last_modified"]
|
||||
)
|
||||
if record.get("last_reminded") is not None:
|
||||
record["last_reminded_value"] = record["last_reminded"]
|
||||
record["last_reminded"] = convert_unix_to_iso(
|
||||
record["last_reminded"]
|
||||
)
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"service": service,
|
||||
"client": client,
|
||||
"client_type": client_type_desc,
|
||||
"auth_value": auth_value_desc,
|
||||
"auth_reason_desc": auth_reason_desc,
|
||||
"last_modified": last_modified,
|
||||
}
|
||||
)
|
||||
else:
|
||||
allowed_value = row[3]
|
||||
allowed_desc = AUTH_VALUE_OLD.get(allowed_value, "")
|
||||
prompt_count = row[4]
|
||||
|
||||
if db_version == "v2":
|
||||
last_modified = convert_unix_to_iso(row[5])
|
||||
if service in ["kTCCServiceMicrophone", "kTCCServiceCamera"]:
|
||||
device = "camera"
|
||||
if service == "kTCCServiceMicrophone":
|
||||
device = "microphone"
|
||||
|
||||
self.log.info(
|
||||
'Found client "%s" with access %s to %s at %s',
|
||||
client,
|
||||
allowed_desc,
|
||||
device,
|
||||
last_modified,
|
||||
)
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"service": service,
|
||||
"client": client,
|
||||
"client_type": client_type_desc,
|
||||
"allowed_value": allowed_desc,
|
||||
"prompt_count": prompt_count,
|
||||
"last_modified": last_modified,
|
||||
}
|
||||
)
|
||||
else:
|
||||
if service in ["kTCCServiceMicrophone", "kTCCServiceCamera"]:
|
||||
device = "camera"
|
||||
if service == "kTCCServiceMicrophone":
|
||||
device = "microphone"
|
||||
|
||||
self.log.info(
|
||||
'Found client "%s" with access %s to %s',
|
||||
client,
|
||||
allowed_desc,
|
||||
device,
|
||||
)
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"service": service,
|
||||
"client": client,
|
||||
"client_type": client_type_desc,
|
||||
"allowed_value": allowed_desc,
|
||||
"prompt_count": prompt_count,
|
||||
}
|
||||
)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
self.results.append(record)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def run(self) -> None:
|
||||
self._find_ios_database(backup_ids=TCC_BACKUP_IDS, root_paths=TCC_ROOT_PATHS)
|
||||
|
||||
@@ -13,7 +13,7 @@ from mvt.common.module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import convert_unix_to_iso
|
||||
from mvt.common.utils import convert_unix_to_iso, sanitize_json_data
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -85,52 +85,45 @@ class WebkitResourceLoadStatistics(IOSExtraction):
|
||||
|
||||
try:
|
||||
try:
|
||||
cur.execute("PRAGMA table_info(ObservedDomains);")
|
||||
available_columns = {row[1] for row in cur}
|
||||
required_columns = [
|
||||
"domainID",
|
||||
"registrableDomain",
|
||||
"lastSeen",
|
||||
"hadUserInteraction",
|
||||
]
|
||||
if not set(required_columns).issubset(available_columns):
|
||||
return
|
||||
|
||||
optional_columns = [
|
||||
column
|
||||
for column in [
|
||||
"mostRecentUserInteractionTime",
|
||||
"mostRecentWebPushInteractionTime",
|
||||
]
|
||||
if column in available_columns
|
||||
]
|
||||
selected_columns = required_columns + optional_columns
|
||||
cur.execute(
|
||||
f"SELECT {', '.join(selected_columns)} FROM ObservedDomains;"
|
||||
)
|
||||
cur.execute("SELECT * FROM ObservedDomains;")
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
|
||||
names = [description[0] for description in cur.description]
|
||||
required_columns = {
|
||||
"domainID",
|
||||
"registrableDomain",
|
||||
"lastSeen",
|
||||
"hadUserInteraction",
|
||||
}
|
||||
if not required_columns.issubset(names):
|
||||
return
|
||||
for row in cur:
|
||||
raw = sanitize_json_data(dict(zip(names, row)))
|
||||
last_seen = raw.get("lastSeen")
|
||||
result = {
|
||||
"domain_id": row[0],
|
||||
"registrable_domain": row[1],
|
||||
"last_seen": row[2],
|
||||
"had_user_interaction": bool(row[3]),
|
||||
"last_seen_isodate": convert_unix_to_iso(row[2]),
|
||||
"domain_id": raw.get("domainID"),
|
||||
"registrable_domain": raw.get("registrableDomain"),
|
||||
"last_seen": last_seen,
|
||||
"had_user_interaction": bool(raw.get("hadUserInteraction")),
|
||||
"last_seen_isodate": convert_unix_to_iso(last_seen),
|
||||
"domain": domain,
|
||||
"path": path,
|
||||
"record": raw,
|
||||
}
|
||||
for index, column in enumerate(optional_columns, start=4):
|
||||
field = {
|
||||
"mostRecentUserInteractionTime": (
|
||||
"most_recent_user_interaction_time"
|
||||
),
|
||||
"mostRecentWebPushInteractionTime": (
|
||||
"most_recent_web_push_interaction_time"
|
||||
),
|
||||
}[column]
|
||||
timestamp = row[index]
|
||||
for column, field in (
|
||||
(
|
||||
"mostRecentUserInteractionTime",
|
||||
"most_recent_user_interaction_time",
|
||||
),
|
||||
(
|
||||
"mostRecentWebPushInteractionTime",
|
||||
"most_recent_web_push_interaction_time",
|
||||
),
|
||||
):
|
||||
if column not in raw:
|
||||
continue
|
||||
timestamp = raw[column]
|
||||
result[field] = timestamp
|
||||
if timestamp is not None and timestamp >= 0:
|
||||
result[f"{field}_isodate"] = convert_unix_to_iso(timestamp)
|
||||
|
||||
@@ -3,9 +3,4 @@
|
||||
# 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 .base import SysdiagnoseExtraction
|
||||
from .sysdiagnose_info import SysdiagnoseInfo
|
||||
|
||||
SYSDIAGNOSE_MODULES: list[type[MVTModule]] = [SysdiagnoseInfo]
|
||||
@@ -1,228 +0,0 @@
|
||||
# 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 logging
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from mvt.ios.versions import (
|
||||
find_version_by_build,
|
||||
get_device_desc_from_id,
|
||||
is_ios_version_outdated,
|
||||
)
|
||||
|
||||
from .base import SysdiagnoseExtraction
|
||||
|
||||
# The fields dumpsys prints in the remotectl dump state and the mobile
|
||||
# activation request which are worth a log line of their own.
|
||||
LOGGED_FIELDS = (
|
||||
"ProductName",
|
||||
"ProductType",
|
||||
"SerialNumber",
|
||||
"OSVersion",
|
||||
"RegionCode",
|
||||
"IMEI",
|
||||
"BuildVersion",
|
||||
)
|
||||
|
||||
|
||||
class SysdiagnoseInfo(SysdiagnoseExtraction):
|
||||
"""Extract details about the device and the sysdiagnose itself.
|
||||
|
||||
The fields come from four files of the archive: the remotectl dump state
|
||||
(product type, OS version, serial number, region and the rest of its
|
||||
Properties block), the mobile activation request (UDID, IMEI, MEID and the
|
||||
OS build), the App Store daemon database (the Apple account name and email)
|
||||
and sysdiagnose.log (the archive's original file name and creation time).
|
||||
|
||||
Newer iOS versions no longer include the App Store daemon database in a
|
||||
sysdiagnose; it is still read for the analysis of older archives.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
self.results: dict = results if results is not None else {}
|
||||
|
||||
def _copy_sqlite_db(self, file_path: str, directory: str) -> str:
|
||||
"""Copy a database and its WAL sidecars out of the archive.
|
||||
|
||||
A database dumped mid-transaction keeps its latest rows in the -wal
|
||||
file next to it, which SQLite only reads when both sit in the same
|
||||
directory under the same name.
|
||||
"""
|
||||
available_files = self.tar_files if self.tar else self.files
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
if suffix and f"{file_path}{suffix}" not in available_files:
|
||||
continue
|
||||
copy_path = os.path.join(directory, f"{Path(file_path).name}{suffix}")
|
||||
with open(copy_path, "wb") as handle:
|
||||
handle.write(self._get_file_content(f"{file_path}{suffix}"))
|
||||
|
||||
return os.path.join(directory, Path(file_path).name)
|
||||
|
||||
def _process_appstored(self, file_path: str) -> None:
|
||||
self.log.info("Found App Store daemon database at: %s", file_path)
|
||||
with tempfile.TemporaryDirectory(prefix="mvt_sqlite_") as directory:
|
||||
db_path = Path(self._copy_sqlite_db(file_path, directory)).resolve()
|
||||
conn = sqlite3.connect(f"{db_path.as_uri()}?mode=ro", uri=True)
|
||||
try:
|
||||
self._read_appstored(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _read_appstored(self, conn: sqlite3.Connection) -> None:
|
||||
cur = conn.cursor()
|
||||
# The account name sits in an opaque structure of every asset row.
|
||||
try:
|
||||
rows = cur.execute("SELECT sinfs_data FROM asset;").fetchall()
|
||||
except sqlite3.DatabaseError as exc:
|
||||
self.log.debug("Unable to read the asset table: %s", exc)
|
||||
rows = []
|
||||
|
||||
for (sinfs_data,) in rows:
|
||||
try:
|
||||
sinf = plistlib.loads(sinfs_data)[0]["sinf"]
|
||||
except (plistlib.InvalidFileException, IndexError, KeyError, TypeError):
|
||||
continue
|
||||
match = re.search(rb"name(.*?)\x00", sinf)
|
||||
if match:
|
||||
self.results["Account Name"] = match.group(1).decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
row = cur.execute(
|
||||
"SELECT store_account_name FROM job_software "
|
||||
"WHERE store_account_name IS NOT NULL LIMIT 1;"
|
||||
).fetchone()
|
||||
except sqlite3.DatabaseError as exc:
|
||||
self.log.debug("Unable to read the job_software table: %s", exc)
|
||||
return
|
||||
|
||||
if row:
|
||||
self.results["Email Address"] = row[0]
|
||||
|
||||
def _process_activation_log(self, file_path: str) -> None:
|
||||
self.log.info("Found mobile activation request at: %s", file_path)
|
||||
content = self._get_file_content(file_path)
|
||||
match = re.search(rb"BODY:\s+({.+?})\s", content, re.MULTILINE)
|
||||
if not match:
|
||||
return
|
||||
|
||||
try:
|
||||
body = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
self.log.warning("Unable to parse the activation request body: %s", exc)
|
||||
return
|
||||
|
||||
self.results.update(
|
||||
{
|
||||
"SerialNumber": body.get("serial-number"),
|
||||
"ProductType": body.get("productType"),
|
||||
"ProductName": body.get("productName"),
|
||||
"IMEI": body.get("imei"),
|
||||
"ProductVersion": body.get("os-version"),
|
||||
"UniqueIdentifier": body.get("udid"),
|
||||
"MEID": body.get("meid"),
|
||||
"BuildVersion": body.get("os-build"),
|
||||
}
|
||||
)
|
||||
|
||||
def _process_dumpstate(self, file_path: str) -> None:
|
||||
self.log.info("Found remotectl dump state at: %s", file_path)
|
||||
content = self._get_file_content(file_path).decode("utf-8", errors="replace")
|
||||
in_properties = False
|
||||
for line in content.splitlines():
|
||||
if not in_properties:
|
||||
in_properties = line == "\tProperties: {"
|
||||
continue
|
||||
|
||||
if line == "\t}":
|
||||
break
|
||||
|
||||
key, separator, value = line.partition("=>")
|
||||
if separator:
|
||||
self.results[key.strip()] = value.strip()
|
||||
|
||||
def _process_sysdiagnose_log(self, file_path: str) -> None:
|
||||
self.log.info("Found sysdiagnose.log at: %s", file_path)
|
||||
content = self._get_file_content(file_path).decode("utf-8", errors="replace")
|
||||
match = re.search(r"sysdiagnose_\S+?\.tar\.gz", content)
|
||||
if not match:
|
||||
self.log.info("Could not find the original output path in sysdiagnose.log")
|
||||
return
|
||||
|
||||
file_name = os.path.basename(match.group(0))
|
||||
try:
|
||||
created = datetime.strptime(
|
||||
"_".join(file_name.split("_")[1:3]), "%Y.%m.%d_%H-%M-%S%z"
|
||||
)
|
||||
except ValueError:
|
||||
self.log.warning("Unexpected sysdiagnose file name: %s", file_name)
|
||||
return
|
||||
|
||||
self.results["OriginalFilename"] = file_name
|
||||
self.results["CreatedTimestamp"] = convert_datetime_to_iso(created)
|
||||
|
||||
def run(self) -> None:
|
||||
for file_path in self._get_files_by_pattern(
|
||||
"*/logs/appinstallation/appstored.sqlitedb"
|
||||
):
|
||||
self._process_appstored(file_path)
|
||||
|
||||
for file_path in self._get_files_by_pattern(
|
||||
"*/logs/MobileActivation/collection_oob_request.txt"
|
||||
):
|
||||
self._process_activation_log(file_path)
|
||||
|
||||
for file_path in self._get_files_by_pattern("*/remotectl_dumpstate.txt"):
|
||||
self._process_dumpstate(file_path)
|
||||
|
||||
for file_path in self._get_files_by_pattern("*/sysdiagnose.log"):
|
||||
self._process_sysdiagnose_log(file_path)
|
||||
|
||||
# The activation request names the product "iPhone OS"; the model
|
||||
# description is what an analyst wants to read.
|
||||
product_name = get_device_desc_from_id(self.results.get("ProductType", ""))
|
||||
if product_name:
|
||||
self.results["ProductName"] = product_name
|
||||
|
||||
for field in LOGGED_FIELDS:
|
||||
if field not in self.results:
|
||||
continue
|
||||
value = self.results[field]
|
||||
if field == "BuildVersion" and value:
|
||||
self.log.info("%s: %s - %s", field, value, find_version_by_build(value))
|
||||
else:
|
||||
self.log.info("%s: %s", field, value)
|
||||
|
||||
if self.results.get("BuildVersion"):
|
||||
is_ios_version_outdated(self.results["BuildVersion"], self.log)
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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/
|
||||
"""A mount is writable only if both mountinfo option layers allow it."""
|
||||
|
||||
from mvt.android.artifacts.mounts import Mounts
|
||||
from mvt.common.alerts import AlertLevel
|
||||
|
||||
# fields[5] is the per-mount (VFS) layer, the field after "-" the superblock.
|
||||
RO_OVERLAY = (
|
||||
"304 166 0:130 / /product/usr rw,relatime shared:63 - overlay overlay "
|
||||
"ro,seclabel,lowerdir=/mnt/vendor/ext/product/usr:/product/usr"
|
||||
)
|
||||
RW_SYSTEM = (
|
||||
"305 166 0:131 / /system rw,relatime shared:64 - ext4 /dev/block/dm-1 "
|
||||
"rw,seclabel,errors=panic"
|
||||
)
|
||||
|
||||
|
||||
def _run(line):
|
||||
mounts = Mounts()
|
||||
mounts.results = Mounts.parse_mountinfo(line, 1)
|
||||
mounts.check_indicators()
|
||||
return mounts
|
||||
|
||||
|
||||
class TestMountsReadWriteLayers:
|
||||
def test_rw_vfs_over_ro_superblock_is_not_writable(self):
|
||||
mounts = _run(RO_OVERLAY)
|
||||
assert mounts.results[0]["is_read_write"] is False
|
||||
assert mounts.alertstore.count(AlertLevel.HIGH) == 0
|
||||
assert mounts.alertstore.count(AlertLevel.MEDIUM) == 0
|
||||
|
||||
def test_rw_in_both_layers_still_alerts(self):
|
||||
mounts = _run(RW_SYSTEM)
|
||||
assert mounts.results[0]["is_read_write"] is True
|
||||
assert mounts.alertstore.count(AlertLevel.HIGH) == 1
|
||||
|
||||
def test_both_option_layers_are_still_reported(self):
|
||||
mounts = _run(RO_OVERLAY)
|
||||
options = mounts.results[0]["options_list"]
|
||||
assert "rw" in options and "ro" in options
|
||||
@@ -5,13 +5,8 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
@@ -202,86 +197,6 @@ class RecordingCommand(Command):
|
||||
|
||||
|
||||
class TestCommand:
|
||||
def test_listing_modules_does_not_load_indicators(self):
|
||||
with patch("mvt.common.command.Indicators.load_indicators_files") as load:
|
||||
cmd = RecordingCommand()
|
||||
cmd.list_modules()
|
||||
load.assert_not_called()
|
||||
|
||||
def test_output_folder_is_created_by_a_run_not_by_listing(self, tmp_path):
|
||||
output_path = tmp_path / "out"
|
||||
with patch("mvt.common.command.Indicators.load_indicators_files"):
|
||||
cmd = RecordingCommand(results_path=str(output_path))
|
||||
cmd.list_modules()
|
||||
assert not output_path.exists()
|
||||
cmd.run()
|
||||
assert (output_path / "command.log").is_file()
|
||||
|
||||
def test_indicators_load_once_and_are_shared(self, indicator_file, monkeypatch):
|
||||
from mvt.common.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "STIX2", "")
|
||||
monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None)
|
||||
original = Indicators.load_indicators_files
|
||||
with patch.object(
|
||||
Indicators, "load_indicators_files", autospec=True, side_effect=original
|
||||
) as load:
|
||||
cmd = RecordingCommand(ioc_files=[indicator_file])
|
||||
load.assert_not_called()
|
||||
indicators = cmd.iocs
|
||||
assert indicators.total_ioc_count == 9
|
||||
assert len(indicators.ioc_collections) == 1
|
||||
assert cmd.iocs is indicators
|
||||
child = RecordingCommand(iocs=indicators)
|
||||
assert child.iocs is indicators
|
||||
load.assert_called_once_with(indicators, [indicator_file])
|
||||
|
||||
@pytest.mark.parametrize("assign", [False, True])
|
||||
def test_supplied_empty_indicators_are_not_loaded(self, assign):
|
||||
indicators = Indicators(logging.getLogger(__name__))
|
||||
with patch.object(Indicators, "load_indicators_files") as load:
|
||||
cmd = RecordingCommand(iocs=None if assign else indicators)
|
||||
if assign:
|
||||
cmd.iocs = indicators
|
||||
assert cmd.iocs is indicators
|
||||
assert cmd.iocs.total_ioc_count == 0
|
||||
load.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("list_modules", [False, True])
|
||||
def test_backup_cli_does_not_load_indicators_before_analysis(
|
||||
self, tmp_path, list_modules
|
||||
):
|
||||
from mvt.ios.cli import check_backup
|
||||
|
||||
args = [str(tmp_path)]
|
||||
if list_modules:
|
||||
args.insert(0, "--list-modules")
|
||||
with patch.object(Indicators, "load_indicators_files") as load:
|
||||
result = CliRunner().invoke(check_backup, args)
|
||||
assert result.exit_code == (0 if list_modules else 1)
|
||||
load.assert_not_called()
|
||||
|
||||
def test_run_checks_synthetic_indicators(self, indicator_file, monkeypatch):
|
||||
from mvt.common.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "STIX2", "")
|
||||
monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None)
|
||||
|
||||
class MatchingModule(RecordingModule):
|
||||
def run(self):
|
||||
self.results = ["https://example.org/test"]
|
||||
|
||||
def check_indicators(self):
|
||||
self.detected = [
|
||||
url for url in self.results if self.indicators.check_domain(url)
|
||||
]
|
||||
|
||||
cmd = RecordingCommand(ioc_files=[indicator_file])
|
||||
cmd.modules = [MatchingModule]
|
||||
cmd.run()
|
||||
assert cmd.executed[0].detected == ["https://example.org/test"]
|
||||
assert cmd.executed[0].indicators is cmd.iocs
|
||||
|
||||
def setup_method(self):
|
||||
RecordingModule.run_order = []
|
||||
|
||||
|
||||
@@ -12,16 +12,13 @@ from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES
|
||||
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
|
||||
from mvt.ios.modules.sysdiagnose import SYSDIAGNOSE_MODULES
|
||||
|
||||
|
||||
def test_the_check_iocs_lists_are_the_families_of_their_platform():
|
||||
# The CLI reads these same lists, so nothing composing one elsewhere can
|
||||
# drift from what the command runs. This pins what the lists are composed
|
||||
# of.
|
||||
assert IOS_CHECK_IOCS_MODULES == (
|
||||
IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES
|
||||
)
|
||||
assert IOS_CHECK_IOCS_MODULES == IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
assert ANDROID_CHECK_IOCS_MODULES == (
|
||||
ANDROID_BACKUP_MODULES
|
||||
+ BUGREPORT_MODULES
|
||||
|
||||
@@ -18,6 +18,7 @@ from mvt.common.utils import (
|
||||
generate_hashes_from_path,
|
||||
get_sha256_from_file_path,
|
||||
init_logging,
|
||||
sanitize_json_data,
|
||||
set_verbose_logging,
|
||||
)
|
||||
|
||||
@@ -108,6 +109,18 @@ class TestCustomJSONEncoder:
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_json_data_preserves_nested_binary_and_dates():
|
||||
value = {
|
||||
"blob": b"\x00\xff",
|
||||
"nested": [datetime(2023, 11, 13, 12, 21, 49, 727467)],
|
||||
}
|
||||
|
||||
assert sanitize_json_data(value) == {
|
||||
"blob": "AP8=",
|
||||
"nested": ["2023-11-13 12:21:49.727467"],
|
||||
}
|
||||
|
||||
|
||||
class TestInitLogging:
|
||||
def test__init_logging_is_idempotent(self):
|
||||
# Loaded module packages may import an MVT CLI module, which calls
|
||||
|
||||
+7
-20
@@ -3,25 +3,19 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from .artifacts.generate_stix import generate_test_stix_file
|
||||
from mvt.common.cli_plugins import (
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
from mvt.common.indicators import Indicators
|
||||
|
||||
# The suite must neither read nor write the developer's own MVT settings,
|
||||
# downloaded indicators or update-check state, and mvt.common.config saves
|
||||
# the settings file as soon as it is imported. Both folders are redirected
|
||||
# before any mvt module is imported, which is why this file imports none at
|
||||
# the top; the subprocesses the tests start inherit the variables.
|
||||
MVT_TEST_HOME = tempfile.mkdtemp(prefix="mvt-tests-")
|
||||
atexit.register(shutil.rmtree, MVT_TEST_HOME, ignore_errors=True)
|
||||
os.environ["MVT_CONFIG_FOLDER"] = os.path.join(MVT_TEST_HOME, "config")
|
||||
os.environ["MVT_DATA_FOLDER"] = os.path.join(MVT_TEST_HOME, "data")
|
||||
from .artifacts.generate_stix import generate_test_stix_file
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
@@ -53,8 +47,6 @@ def indicators_factory(indicator_file):
|
||||
android_property_names=[],
|
||||
files_sha256=[],
|
||||
):
|
||||
from mvt.common.indicators import Indicators
|
||||
|
||||
ind = Indicators(log=logging.getLogger())
|
||||
ind.parse_stix2(indicator_file)
|
||||
|
||||
@@ -85,11 +77,6 @@ def restore_cli_commands(monkeypatch):
|
||||
"""
|
||||
from mvt.android.cli import cli as android_cli
|
||||
from mvt.cli import cli as neutral_cli
|
||||
from mvt.common.cli_plugins import (
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
|
||||
groups = (neutral_cli, ios_cli, android_cli)
|
||||
|
||||
@@ -21,6 +21,8 @@ class TestCalendarModule:
|
||||
assert len(m.timeline) == 4
|
||||
assert len(m.alertstore.alerts) == 0
|
||||
assert m.results[0]["summary"] == "Super interesting meeting"
|
||||
assert m.results[0]["record"]["calendar_app_link"] is None
|
||||
assert "participant_proposed_start_date" in m.results[0]["record"]
|
||||
|
||||
def test_calendar_with_explicit_file_path(self):
|
||||
backup_path = get_ios_backup_folder()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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
|
||||
|
||||
from mvt.common.module import run_module
|
||||
from mvt.ios.modules.mixed.calls import Calls
|
||||
|
||||
|
||||
def test_calls_preserves_complete_source_record(tmp_path):
|
||||
db_path = tmp_path / "CallHistory.storedata"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE ZCALLRECORD (
|
||||
ZDATE REAL, ZDURATION REAL, ZLOCATION TEXT, ZADDRESS BLOB,
|
||||
ZSERVICE_PROVIDER TEXT, ZORIGINATED INTEGER, ZANSWERED INTEGER,
|
||||
ZMETADATA BLOB
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO ZCALLRECORD VALUES (?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(700000000, 42, "Berlin", b"+491234", "carrier", 1, 0, b"\x00\xff"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
module = Calls(file_path=str(db_path))
|
||||
run_module(module)
|
||||
|
||||
assert len(module.results) == 1
|
||||
assert module.results[0]["number"] == "+491234"
|
||||
assert module.results[0]["call"]["ZORIGINATED"] == 1
|
||||
assert module.results[0]["call"]["ZANSWERED"] == 0
|
||||
assert module.results[0]["call"]["ZMETADATA"] == "AP8="
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
|
||||
from mvt.common.module import run_module
|
||||
from mvt.ios.modules.mixed.contacts import Contacts
|
||||
|
||||
|
||||
def test_contacts_preserves_complete_rows_and_people_without_values(tmp_path):
|
||||
db_path = tmp_path / "AddressBook.sqlitedb"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE ABPerson (
|
||||
ROWID INTEGER PRIMARY KEY, First TEXT, Middle TEXT, Last TEXT,
|
||||
Organization TEXT, Note TEXT
|
||||
);
|
||||
CREATE TABLE ABMultiValue (
|
||||
ROWID INTEGER PRIMARY KEY, record_id INTEGER, value TEXT,
|
||||
label INTEGER
|
||||
);
|
||||
INSERT INTO ABPerson VALUES (1, 'Alice', NULL, 'Example', NULL, 'note');
|
||||
INSERT INTO ABPerson VALUES (2, 'Bob', NULL, 'NoValue', NULL, 'retained');
|
||||
INSERT INTO ABMultiValue VALUES (10, 1, '+491234', 3);
|
||||
"""
|
||||
)
|
||||
conn.close()
|
||||
|
||||
module = Contacts(file_path=str(db_path))
|
||||
run_module(module)
|
||||
|
||||
assert len(module.results) == 2
|
||||
alice = next(result for result in module.results if result["first"] == "Alice")
|
||||
bob = next(result for result in module.results if result["first"] == "Bob")
|
||||
assert alice["contact"]["person_Note"] == "note"
|
||||
assert alice["contact"]["multivalue_label"] == 3
|
||||
assert alice["First"] == "Alice"
|
||||
assert bob["value"] is None
|
||||
assert bob["contact"]["person_Note"] == "retained"
|
||||
@@ -20,6 +20,8 @@ class TestDatausageModule:
|
||||
assert m.results[0]["isodate"][0:19] == "2019-08-27 15:08:09"
|
||||
assert len(m.results) == 42
|
||||
assert len(m.timeline) == 60
|
||||
assert "live_ZBILLCYCLEEND" in m.results[0]["record"]
|
||||
assert "process_Z_ENT" in m.results[0]["record"]
|
||||
assert (
|
||||
len(m.alertstore.alerts) == 1
|
||||
) # We now have a detection for missing processes.
|
||||
|
||||
@@ -20,6 +20,10 @@ class TestInteractionCModule:
|
||||
run_module(m)
|
||||
|
||||
assert len(m.results) == 3
|
||||
assert all(
|
||||
result["interaction"]["Z_PK"] == result["table_id"] for result in m.results
|
||||
)
|
||||
assert all("ZMECHANISM" in result["interaction"] for result in m.results)
|
||||
|
||||
incoming = next(
|
||||
r for r in m.results if r["sender_identifier"] == "100000000000001@lid"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -52,6 +53,7 @@ class TestSafariBrowserStateModule:
|
||||
assert len(m.results) == 1
|
||||
assert len(m.timeline) == 1
|
||||
assert len(m.alertstore.alerts) == 0
|
||||
assert "tab" in m.results[0]
|
||||
|
||||
def test_parsing_backup_with_profile(self, backup_with_safari_profile):
|
||||
m = SafariBrowserState(target_path=backup_with_safari_profile)
|
||||
@@ -74,3 +76,30 @@ class TestSafariBrowserStateModule:
|
||||
assert len(m.alertstore.alerts) == 1
|
||||
assert len(m.results) == 1
|
||||
assert m.results[0]["tab_url"] == "https://en.wikipedia.org/wiki/NSO_Group"
|
||||
|
||||
def test_tabs_without_session_rows_are_not_dropped(self, tmp_path):
|
||||
db_path = tmp_path / "BrowserState.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE tabs (
|
||||
uuid TEXT, title TEXT, url TEXT, user_visible_url TEXT,
|
||||
last_viewed_time REAL, private_browsing INTEGER
|
||||
);
|
||||
CREATE TABLE tab_sessions (tab_uuid TEXT, session_data BLOB);
|
||||
INSERT INTO tabs VALUES (
|
||||
'tab-1', 'Example', 'https://example.test',
|
||||
'https://example.test', 700000000, 1
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.close()
|
||||
module = SafariBrowserState(target_path=str(tmp_path))
|
||||
|
||||
module._process_browser_state_db(str(db_path))
|
||||
|
||||
assert len(module.results) == 1
|
||||
assert module.results[0]["tab_url"] == "https://example.test"
|
||||
assert module.results[0]["session_data"] == []
|
||||
assert module.results[0]["tab"]["private_browsing"] == 1
|
||||
assert module.results[0]["tab"]["session_data"] is None
|
||||
@@ -105,6 +105,7 @@ class TestSafariHistoryModule:
|
||||
assert len(m.results) == 2
|
||||
assert {result["url"] for result in m.results} == {DEFAULT_URL, PROFILE_URL}
|
||||
assert len({result["safari_history_db"] for result in m.results}) == 2
|
||||
assert all("visit_history_item" in result["record"] for result in m.results)
|
||||
|
||||
def test_parsing_fs_dump_with_profile(self, fs_dump_with_safari_profile):
|
||||
m = SafariHistory(target_path=fs_dump_with_safari_profile)
|
||||
|
||||
@@ -22,6 +22,9 @@ class TestTCCModule:
|
||||
assert m.results[0]["service"] == "kTCCServiceUbiquity"
|
||||
assert m.results[0]["client"] == "com.apple.Preferences"
|
||||
assert m.results[0]["auth_value"] == "allowed"
|
||||
assert m.results[0]["auth_value_value"] == 2
|
||||
assert "policy_id" in m.results[0]
|
||||
assert "csreq" in m.results[0]
|
||||
|
||||
def test_tcc_detection(self, indicator_file):
|
||||
m = TCC(target_path=get_ios_backup_folder())
|
||||
|
||||
@@ -34,6 +34,7 @@ class TestWebkitResourceLoadStatisticsModule:
|
||||
"most_recent_web_push_interaction_time" not in result
|
||||
for result in m.results
|
||||
)
|
||||
assert all("record" in result for result in m.results)
|
||||
|
||||
def test_webkit_full_timestamp_schema(self, tmp_path):
|
||||
db_path = tmp_path / "observations.db"
|
||||
@@ -68,3 +69,4 @@ class TestWebkitResourceLoadStatisticsModule:
|
||||
assert "most_recent_user_interaction_time_isodate" in result
|
||||
assert result["most_recent_web_push_interaction_time"] == -1.0
|
||||
assert "most_recent_web_push_interaction_time_isodate" not in result
|
||||
assert result["record"]["domainID"] == 1
|
||||
@@ -1,4 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,161 +0,0 @@
|
||||
# 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 plistlib
|
||||
import sqlite3
|
||||
import tarfile
|
||||
|
||||
from mvt.common.module import run_module
|
||||
from mvt.ios.cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
|
||||
from mvt.ios.modules.sysdiagnose.sysdiagnose_info import SysdiagnoseInfo
|
||||
from mvt.ios.versions import get_device_desc_from_id
|
||||
|
||||
# The name sysdiagnose gives its archive: the time it ran, then the OS and build.
|
||||
ARCHIVE_NAME = "sysdiagnose_2024.01.02_03-04-05+0200_iPhone-OS_iPhone_21C62"
|
||||
|
||||
DUMPSTATE = (
|
||||
"Found device: ...\n"
|
||||
"\tProperties: {\n"
|
||||
"\t\tProductType => iPhone12,1\n"
|
||||
"\t\tOSVersion => 17.2\n"
|
||||
"\t\tSerialNumber => C0FFEE000000\n"
|
||||
"\t\tRegionCode => LL\n"
|
||||
"\t}\n"
|
||||
"\tServices: {\n"
|
||||
"\t\tcom.apple.example => ignored\n"
|
||||
"\t}\n"
|
||||
)
|
||||
|
||||
ACTIVATION_BODY = {
|
||||
"serial-number": "C0FFEE000000",
|
||||
"productType": "iPhone12,1",
|
||||
"productName": "iPhone OS",
|
||||
"imei": "000000000000000",
|
||||
"os-version": "17.2",
|
||||
"os-build": "21C62",
|
||||
"udid": "00000000-0000000000000000",
|
||||
"meid": "00000000000000",
|
||||
}
|
||||
|
||||
|
||||
def make_sysdiagnose(tmp_path, activation_body=None):
|
||||
folder = tmp_path / ARCHIVE_NAME
|
||||
folder.mkdir()
|
||||
(folder / "sysdiagnose.log").write_text(
|
||||
f"Output available at '/private/var/tmp/{ARCHIVE_NAME}.tar.gz'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(folder / "remotectl_dumpstate.txt").write_text(DUMPSTATE, encoding="utf-8")
|
||||
|
||||
activation = folder / "logs" / "MobileActivation"
|
||||
activation.mkdir(parents=True)
|
||||
body = json.dumps(
|
||||
activation_body if activation_body is not None else ACTIVATION_BODY
|
||||
)
|
||||
(activation / "collection_oob_request.txt").write_text(
|
||||
f"HEADERS: {{}}\nBODY: {body}\nEND\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
appinstallation = folder / "logs" / "appinstallation"
|
||||
appinstallation.mkdir(parents=True)
|
||||
conn = sqlite3.connect(appinstallation / "appstored.sqlitedb")
|
||||
conn.execute("CREATE TABLE asset (sinfs_data BLOB)")
|
||||
conn.execute(
|
||||
"INSERT INTO asset VALUES (?)",
|
||||
(plistlib.dumps([{"sinf": b"\x00\x10nameExample Person\x00\x00rest"}]),),
|
||||
)
|
||||
conn.execute("CREATE TABLE job_software (store_account_name TEXT)")
|
||||
conn.execute("INSERT INTO job_software VALUES (NULL)")
|
||||
conn.execute("INSERT INTO job_software VALUES ('person@example.com')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return folder
|
||||
|
||||
|
||||
def run_command(target, results_path=None):
|
||||
command = CmdIOSCheckSysdiagnose(target_path=str(target), results_path=results_path)
|
||||
command.run()
|
||||
(module,) = [m for m in command.executed if isinstance(m, SysdiagnoseInfo)]
|
||||
return module
|
||||
|
||||
|
||||
def test_device_details_from_a_sysdiagnose_folder(tmp_path):
|
||||
results_path = tmp_path / "results"
|
||||
results_path.mkdir()
|
||||
module = run_command(make_sysdiagnose(tmp_path), str(results_path))
|
||||
|
||||
assert module.results["SerialNumber"] == "C0FFEE000000"
|
||||
assert module.results["ProductType"] == "iPhone12,1"
|
||||
assert module.results["ProductName"] == get_device_desc_from_id("iPhone12,1")
|
||||
assert module.results["ProductName"] != "iPhone OS"
|
||||
assert module.results["OSVersion"] == "17.2"
|
||||
assert module.results["BuildVersion"] == "21C62"
|
||||
assert module.results["UniqueIdentifier"] == "00000000-0000000000000000"
|
||||
assert module.results["RegionCode"] == "LL"
|
||||
assert "com.apple.example" not in module.results
|
||||
assert module.results["Account Name"] == "Example Person"
|
||||
assert module.results["Email Address"] == "person@example.com"
|
||||
assert module.results["OriginalFilename"] == f"{ARCHIVE_NAME}.tar.gz"
|
||||
assert module.results["CreatedTimestamp"] == "2024-01-02 01:04:05.000000"
|
||||
assert (results_path / "sysdiagnose_info.json").exists()
|
||||
|
||||
|
||||
def test_device_details_from_a_sysdiagnose_archive(tmp_path):
|
||||
folder = make_sysdiagnose(tmp_path)
|
||||
archive_path = tmp_path / f"{ARCHIVE_NAME}.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
archive.add(folder, arcname=ARCHIVE_NAME)
|
||||
|
||||
module = run_command(archive_path)
|
||||
|
||||
assert module.results["SerialNumber"] == "C0FFEE000000"
|
||||
assert module.results["Account Name"] == "Example Person"
|
||||
assert module.results["OriginalFilename"] == f"{ARCHIVE_NAME}.tar.gz"
|
||||
|
||||
|
||||
def test_wal_sidecars_are_copied_beside_the_database(tmp_path):
|
||||
folder = tmp_path / ARCHIVE_NAME
|
||||
(folder / "logs").mkdir(parents=True)
|
||||
(folder / "logs" / "db.sqlite").write_bytes(b"main")
|
||||
(folder / "logs" / "db.sqlite-wal").write_bytes(b"wal")
|
||||
module = SysdiagnoseInfo()
|
||||
module.from_sysdiagnose_folder(
|
||||
str(folder),
|
||||
[f"{ARCHIVE_NAME}/logs/db.sqlite", f"{ARCHIVE_NAME}/logs/db.sqlite-wal"],
|
||||
)
|
||||
copies = tmp_path / "copies"
|
||||
copies.mkdir()
|
||||
|
||||
db_path = module._copy_sqlite_db(f"{ARCHIVE_NAME}/logs/db.sqlite", str(copies))
|
||||
|
||||
assert db_path == str(copies / "db.sqlite")
|
||||
assert (copies / "db.sqlite").read_bytes() == b"main"
|
||||
assert (copies / "db.sqlite-wal").read_bytes() == b"wal"
|
||||
assert not (copies / "db.sqlite-shm").exists()
|
||||
|
||||
|
||||
def test_a_sysdiagnose_without_the_files_yields_nothing(tmp_path):
|
||||
folder = tmp_path / ARCHIVE_NAME
|
||||
folder.mkdir()
|
||||
(folder / "other.txt").write_text("nothing here", encoding="utf-8")
|
||||
|
||||
module = SysdiagnoseInfo()
|
||||
module.from_sysdiagnose_folder(str(folder), [f"{ARCHIVE_NAME}/other.txt"])
|
||||
run_module(module)
|
||||
|
||||
assert module.results == {}
|
||||
|
||||
|
||||
def test_a_malformed_activation_request_is_skipped(tmp_path):
|
||||
folder = make_sysdiagnose(tmp_path)
|
||||
(folder / "logs" / "MobileActivation" / "collection_oob_request.txt").write_text(
|
||||
"BODY: {not json}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
module = run_command(folder)
|
||||
|
||||
assert "IMEI" not in module.results
|
||||
assert module.results["SerialNumber"] == "C0FFEE000000"
|
||||
@@ -3,16 +3,11 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.android.cli import check_bugreport
|
||||
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
|
||||
from .utils import get_artifact_folder
|
||||
|
||||
@@ -33,90 +28,3 @@ class TestCheckBugreportCommand:
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid bugreport archive" in result.output
|
||||
assert "Traceback" not in result.output
|
||||
|
||||
|
||||
PROPERTIES = (
|
||||
"------ SYSTEM PROPERTIES (getprop) ------\n"
|
||||
"[persist.sys.timezone]: [Africa/Nairobi]\n"
|
||||
"------ 0.01s was the duration of 'SYSTEM PROPERTIES' ------\n"
|
||||
)
|
||||
TOMBSTONE = "android_data/bugreport/FS/data/tombstones/tombstone_00"
|
||||
|
||||
|
||||
def _bugreport_zip(tmp_path, dumpstate=PROPERTIES):
|
||||
"""A bugreport zip holding one tombstone written at 11:38:10 device time.
|
||||
|
||||
An even second: zip entry times have a two-second resolution.
|
||||
"""
|
||||
path = tmp_path / "bugreport.zip"
|
||||
with open(os.path.join(get_artifact_folder(), TOMBSTONE), "rb") as handle:
|
||||
tombstone = handle.read()
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("main_entry.txt", "dumpstate.txt")
|
||||
archive.writestr("dumpstate.txt", dumpstate)
|
||||
entry = zipfile.ZipInfo(
|
||||
"FS/data/tombstones/tombstone_00", date_time=(2023, 3, 10, 11, 38, 10)
|
||||
)
|
||||
archive.writestr(entry, tombstone)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _tombstone_timestamp(target, **options):
|
||||
cmd = CmdAndroidCheckBugreport(
|
||||
target_path=target,
|
||||
module_name="Tombstones",
|
||||
disable_version_check=True,
|
||||
disable_indicator_check=True,
|
||||
**options,
|
||||
)
|
||||
cmd.run()
|
||||
return cmd, cmd.executed[0].results[0]["file_timestamp"]
|
||||
|
||||
|
||||
class TestCheckBugreportTimezone:
|
||||
def test_zip_entry_times_are_read_in_the_device_timezone(self, tmp_path):
|
||||
cmd, file_timestamp = _tombstone_timestamp(_bugreport_zip(tmp_path))
|
||||
|
||||
assert cmd.module_options["device_timezone"] == "Africa/Nairobi"
|
||||
# 11:38:10 in Nairobi is 08:38:10 UTC.
|
||||
assert file_timestamp == "2023-03-10 08:38:10.000000"
|
||||
|
||||
def test_timezone_option_wins_over_the_bugreport(self, tmp_path):
|
||||
_, file_timestamp = _tombstone_timestamp(
|
||||
_bugreport_zip(tmp_path), module_options={"device_timezone": "Europe/Paris"}
|
||||
)
|
||||
|
||||
assert file_timestamp == "2023-03-10 10:38:10.000000"
|
||||
|
||||
result = CliRunner().invoke(
|
||||
check_bugreport,
|
||||
["-t", "Europe/Paris", "-m", "Tombstones", _bugreport_zip(tmp_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
def test_without_a_timezone_the_wall_clock_is_kept_and_a_warning_given(
|
||||
self, tmp_path, caplog
|
||||
):
|
||||
with caplog.at_level(logging.WARNING, logger="mvt"):
|
||||
_, file_timestamp = _tombstone_timestamp(_bugreport_zip(tmp_path, ""))
|
||||
|
||||
assert file_timestamp == "2023-03-10 11:38:10.000000"
|
||||
assert "persist.sys.timezone not found" in caplog.text
|
||||
|
||||
def test_unpacked_bugreport_warns_and_reads_mtimes_as_utc(self, tmp_path, caplog):
|
||||
unpacked = tmp_path / "bugreport"
|
||||
shutil.copytree(
|
||||
os.path.join(get_artifact_folder(), "android_data/bugreport"), unpacked
|
||||
)
|
||||
instant = datetime.datetime(
|
||||
2023, 3, 10, 8, 38, 11, tzinfo=datetime.timezone.utc
|
||||
).timestamp()
|
||||
for name in ("tombstone_00", "tombstone_01"):
|
||||
os.utime(unpacked / "FS" / "data" / "tombstones" / name, (instant, instant))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mvt"):
|
||||
_, file_timestamp = _tombstone_timestamp(str(unpacked))
|
||||
|
||||
assert "unpacked bugreport" in caplog.text
|
||||
# Whatever the zone of the machine running the analysis.
|
||||
assert file_timestamp == "2023-03-10 08:38:11.000000"
|
||||
@@ -19,14 +19,6 @@ class TestCheckBackupCommand:
|
||||
result = runner.invoke(check_backup, [path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_check_logs_the_backup_path_to_the_command_log(self, tmp_path):
|
||||
path = get_ios_backup_folder()
|
||||
output_path = tmp_path / "out"
|
||||
result = CliRunner().invoke(check_backup, ["--output", str(output_path), path])
|
||||
assert result.exit_code == 0
|
||||
command_log = (output_path / "command.log").read_text(encoding="utf-8")
|
||||
assert f"Checking iTunes backup located at: {path}" in command_log
|
||||
|
||||
def test_check_finds_backup_in_subfolder(self, tmp_path, caplog):
|
||||
runner = CliRunner()
|
||||
backup_path = tmp_path / "MobileSync" / "Backup" / "device-id"
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.ios.cli import check_sysdiagnose
|
||||
@@ -54,39 +50,8 @@ def test_check_sysdiagnose_runs_explicitly_scoped_custom_module(tmp_path):
|
||||
assert (output_path / "custom_sysdiagnose_module.json").exists()
|
||||
|
||||
|
||||
def test_check_sysdiagnose_warns_without_a_custom_module(tmp_path, caplog):
|
||||
# The built-in SysdiagnoseInfo alone performs no check, so the run goes
|
||||
# ahead but says so.
|
||||
with caplog.at_level(logging.WARNING, logger="mvt"):
|
||||
result = CliRunner().invoke(
|
||||
check_sysdiagnose, [str(_create_sysdiagnose_folder(tmp_path))]
|
||||
)
|
||||
def test_check_sysdiagnose_requires_an_explicitly_scoped_module(tmp_path):
|
||||
result = CliRunner().invoke(check_sysdiagnose, [str(_create_sysdiagnose_folder(tmp_path))])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No forensic sysdiagnose modules have been loaded" in caplog.text
|
||||
|
||||
|
||||
def _create_truncated_sysdiagnose_archive(tmp_path):
|
||||
folder = tmp_path / "sysdiagnose_2026.01.01_00-00-00+0000_iPhone-OS_iPhone_23A000"
|
||||
folder.mkdir()
|
||||
(folder / "sysdiagnose.log").write_bytes(os.urandom(200_000))
|
||||
archive = tmp_path / "sysdiagnose.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
tar.add(folder, arcname=folder.name)
|
||||
data = archive.read_bytes()
|
||||
archive.write_bytes(data[: len(data) // 2])
|
||||
return archive
|
||||
|
||||
|
||||
def test_check_sysdiagnose_reports_a_truncated_archive(tmp_path, caplog):
|
||||
# A download that stopped halfway ends in EOFError from gzip, which Click
|
||||
# would otherwise turn into a bare "Aborted!" with no reason given.
|
||||
archive = _create_truncated_sysdiagnose_archive(tmp_path)
|
||||
|
||||
with caplog.at_level(logging.CRITICAL, logger="mvt"):
|
||||
result = CliRunner().invoke(check_sysdiagnose, [str(archive)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Unable to read the sysdiagnose archive" in caplog.text
|
||||
assert "truncated" in caplog.text
|
||||
assert "Aborted!" not in result.output
|
||||
assert result.exit_code != 0
|
||||
assert "No custom modules support mvt-ios check-sysdiagnose" in result.output
|
||||
@@ -34,7 +34,6 @@ def _create_sysdiagnose_folder(tmp_path):
|
||||
"sysdiagnose_2024.01.02_03-04-05+0200.tar.gz", encoding="utf-8"
|
||||
)
|
||||
(folder / "report.ips").write_text('{"bug_type": 210}\nbody', encoding="utf-8")
|
||||
(folder / "._artifact.txt").write_bytes(b"\x00\x05\x16\x07AppleDouble")
|
||||
return folder
|
||||
|
||||
|
||||
@@ -46,11 +45,6 @@ def _create_sysdiagnose_archive(tmp_path, folder):
|
||||
return archive_path
|
||||
|
||||
|
||||
def _test_module(command):
|
||||
(module,) = [m for m in command.executed if isinstance(m, SysdiagnoseTestModule)]
|
||||
return module
|
||||
|
||||
|
||||
def _run_command(path):
|
||||
command = CmdIOSCheckSysdiagnose(
|
||||
target_path=str(path), custom_modules=[SysdiagnoseTestModule]
|
||||
@@ -62,30 +56,30 @@ def _run_command(path):
|
||||
def test_check_sysdiagnose_from_folder(tmp_path):
|
||||
command = _run_command(_create_sysdiagnose_folder(tmp_path))
|
||||
|
||||
assert _test_module(command).results == [
|
||||
assert command.executed[0].results == [
|
||||
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
|
||||
]
|
||||
assert _test_module(command).ips_files == [
|
||||
assert command.executed[0].ips_files == [
|
||||
{"file_path": str(tmp_path / "sysdiagnose" / "report.ips"), "bug_type": 210}
|
||||
]
|
||||
assert "sysdiagnose/._artifact.txt" not in command.sysdiagnose_files
|
||||
|
||||
|
||||
def test_check_sysdiagnose_from_archive_closes_archive(tmp_path):
|
||||
folder = _create_sysdiagnose_folder(tmp_path)
|
||||
command = _run_command(_create_sysdiagnose_archive(tmp_path, folder))
|
||||
|
||||
assert _test_module(command).results == [
|
||||
assert command.executed[0].results == [
|
||||
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
|
||||
]
|
||||
assert _test_module(command).ips_files == [
|
||||
assert command.executed[0].ips_files == [
|
||||
{
|
||||
"file_path": str(Path(command.extracted_sysdiagnose_path) / "report.ips"),
|
||||
"file_path": str(
|
||||
Path(command.extracted_sysdiagnose_path) / "report.ips"
|
||||
),
|
||||
"bug_type": 210,
|
||||
}
|
||||
]
|
||||
assert command.sysdiagnose_archive is None
|
||||
assert "sysdiagnose/._artifact.txt" not in command.sysdiagnose_files
|
||||
|
||||
|
||||
def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path):
|
||||
|
||||
@@ -6,6 +6,22 @@ resolution-markers = [
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adb-shell"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/73/d246034db6f3e374dad9a35ee3f61345a6b239d4febd2a41ab69df9936fe/adb_shell-0.4.4.tar.gz", hash = "sha256:04c305f30a2ca25d5c54b3cd6ce9bb64c36e5f07967b23b3fb6aaecc851b90b6", size = 61822, upload-time = "2023-09-01T03:48:40.348Z" }
|
||||
|
||||
[package.optional-dependencies]
|
||||
usb = [
|
||||
{ name = "libusb1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
@@ -609,14 +625,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "iphone-backup-decrypt"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/b8/a2ec2b10d792403d36bb6661fea6357179c31ece1ed3285c442e636d6335/iphone_backup_decrypt-0.10.0.tar.gz", hash = "sha256:5f2799e58f908cce28d7929dd82231abc8acd57c25b37bce6a2d6a82e018d343", size = 20000, upload-time = "2026-09-17T21:46:44Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/e7/bcdacdec21d628122ba240e7f742ab2175149e58672be63af55ff37a0f28/iphone_backup_decrypt-0.9.0.tar.gz", hash = "sha256:13b18fef3c8e3af627914f8c1a429bbc5555dfb0505239ba49efe99984cc0c96", size = 16125, upload-time = "2024-09-18T15:50:12.179Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/10/349b2a720e012921d1457e1d1c2b921b16300a5f3570c4c64d0d3787539f/iphone_backup_decrypt-0.10.0-py3-none-any.whl", hash = "sha256:1ac4345a07b104a3eacab20932b8621cd1e0164e0f03d913e66eed6ea5ee173e", size = 17400, upload-time = "2026-09-17T21:46:42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/94/64a31be93f72e0a254bde68e4cf7d24aef37a0a985754a197fa1b028a665/iphone_backup_decrypt-0.9.0-py3-none-any.whl", hash = "sha256:55b5adfafac757f58aa6444b83a4cc2c20cdd699c6ff1d2f4b549936a5dad92c", size = 15767, upload-time = "2024-09-18T15:50:10.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -761,6 +777,17 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libusb1"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/35/f9d2a990d092d647b47540cd229e1d68432c0f51183484ca189612a4824c/libusb1-3.4.0.tar.gz", hash = "sha256:9cf5638506d54f21bf36550d97ea63189111a23c4d8078f630103a2052135f45", size = 91206, upload-time = "2026-05-16T20:59:19.315Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/64/d4b59444e4d3b6979aa5eb58840634465a24b41a9ab03dcf8434c9b89551/libusb1-3.4.0-py3-none-any.whl", hash = "sha256:e83d034e44c3efe1c4599c6281d34bca50a38c12cab3b7b6217d583161a01ffd", size = 67373, upload-time = "2026-05-16T20:59:13.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/55/a838a4278fac4ee49bf670ddc59d621999a1cb2a3188fb13a23609ea4f06/libusb1-3.4.0-py3-none-win32.whl", hash = "sha256:0a1aa1416034690eb9dc9a895eda0fef44d853bca1053eb1de50a5906684846d", size = 129704, upload-time = "2026-05-16T20:59:15.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/08/02aecf6dad627534a5835244ece14d7f187ef430354d9b8551199934d059/libusb1-3.4.0-py3-none-win_amd64.whl", hash = "sha256:b7dcc1f324a895af6aac708bc5513a17373f97349cc2ab8a277519c788bd18ca", size = 141212, upload-time = "2026-05-16T20:59:17.286Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.10.3"
|
||||
@@ -989,11 +1016,13 @@ wheels = [
|
||||
name = "mvt"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "adb-shell", extra = ["usb"] },
|
||||
{ name = "appdirs" },
|
||||
{ name = "betterproto2" },
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "iphone-backup-decrypt" },
|
||||
{ name = "libusb1" },
|
||||
{ name = "nskeyedunarchiver" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyahocorasick" },
|
||||
@@ -1034,11 +1063,13 @@ docs = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "adb-shell", extras = ["usb"], specifier = "==0.4.4" },
|
||||
{ name = "appdirs", specifier = "==1.4.4" },
|
||||
{ name = "betterproto2", specifier = "==0.10.0" },
|
||||
{ name = "click", specifier = "==8.4.2" },
|
||||
{ name = "cryptography", specifier = "==50.0.0" },
|
||||
{ name = "iphone-backup-decrypt", specifier = "==0.10.0" },
|
||||
{ name = "iphone-backup-decrypt", specifier = "==0.9.0" },
|
||||
{ name = "libusb1", specifier = "==3.4.0" },
|
||||
{ name = "nskeyedunarchiver", specifier = "==1.5.2" },
|
||||
{ name = "packaging", specifier = "==26.3" },
|
||||
{ name = "pyahocorasick", specifier = "==2.3.1" },
|
||||
@@ -1243,6 +1274,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/96/37c50ac951bb0260ec38d8d12e5b51587ef1ef4035c279088f2771544b28/pyahocorasick-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4acb11a0a2ff10519465749d22ad70789e9fe7f81dc8fe9957a8868e499e18ab", size = 35987, upload-time = "2026-04-27T16:32:07.08Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@@ -1644,6 +1684,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.2"
|
||||
|
||||
Reference in new issue
Block a user