Compare commits

...
Author SHA1 Message Date
Janik Besendorf eed07c93b3 Merge main and resolve upstream integration conflicts 2026-10-11 14:27:22 +02:00
besendorf b3aa443819 Merge pull request #970 from WingManOO7/fix/settings-leap-day
Keep settings history timestamps on 29 February
2026-10-09 12:52:44 +02:00
besendorf aac41ec8c2 Test settings leap-day resolution across a skipped century
Ensure a February 29 history entry in a January 2104 dump resolves to 2096, since 2100 is not a leap year.
2026-10-09 03:49:54 -07:00
besendorf 1e05f438d7 Include the eighth prior year when resolving settings leap days
Adjust the year range for leap year calculation to account for century years.
2026-10-09 03:48:37 -07:00
Muhammad Mohid Shafiq 10f739d669 Keep settings history timestamps on 29 February
dumpsys prints the settings change history without a year, so
_resolve_timestamp() parsed the value with strptime("%m-%d ...") and only
then replaced the year. strptime() parses a value without a year in 1900,
which has no 29 February, so every change made on a leap day raised
ValueError and lost its timestamp, dropping it from the timeline and from
the alert of a dangerous setting.

Parse the value together with a year instead, starting from the year the
section was dumped and going back until the date exists and is not later
than the dump. Eight years always reach a leap year.

This also removes the DeprecationWarning Python 3.13 raises for parsing a
day of month without a year (python/cpython#70647).
2026-10-08 19:35:29 +05:00
besendorf 94e9cd3712 Merge pull request #968 from va-resident/fix/adb-state-closing-brace
Fix spurious ADB state parse error on devices without stored keys
2026-10-07 17:27:15 +02:00
besendorf 300cc26112 Merge branch 'main' into fix/adb-state-closing-brace 2026-10-07 17:25:29 +02:00
besendorf 3c12e1a019 Merge pull request #925 from mvt-project/docs/recommend-pymobiledevice3
Recommend pymobiledevice3 for iOS acquisition
2026-10-07 16:12:55 +02:00
besendorf c86bc93852 Merge branch 'main' into docs/recommend-pymobiledevice3 2026-10-07 16:11:14 +02:00
va@resident 3a462e1e2c Fix spurious ADB state parse error on devices without stored keys
Since #957 the parsed body keeps the closing brace of debugging_manager
and the blank lines before it. The brace popped two levels, and a blank
line after system_keys raised IndexError. Don't pop when the
indentation drops on a closing brace, and skip blank lines outside
multiline values.

Fixes #967.
2026-10-06 15:29:41 +05:00
besendorf dbbcdd52df Merge branch 'main' into docs/recommend-pymobiledevice3 2026-09-22 16:11:02 +02:00
Janik Besendorf d0cf303aa2 Replace libimobiledevice with pymobiledevice3 in Docker images 2026-09-11 11:21:57 +02:00
Janik Besendorf 86b217a926 Recommend pymobiledevice3 for iOS acquisition 2026-09-11 11:14:53 +02:00
Janik Besendorf 30e25f9692 feat: add versioned output schemas 2026-08-23 10:06:01 +02:00
35 changed files with 946 additions and 342 deletions

No files matched your search

+13 -116
View File
@@ -1,107 +1,3 @@
# Base image for building libraries
# ---------------------------------
FROM ubuntu:22.04 AS build-base
ARG DEBIAN_FRONTEND=noninteractive
# Install build tools and dependencies
RUN apt-get update \
&& apt-get install -y \
build-essential \
git \
autoconf \
automake \
libtool-bin \
pkg-config \
libcurl4-openssl-dev \
libusb-1.0-0-dev \
libssl-dev \
udev \
&& rm -rf /var/lib/apt/lists/*
# libplist
# --------
FROM build-base AS build-libplist
# Build
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libplist
# libimobiledevice-glue
# ---------------------
FROM build-base AS build-libimobiledevice-glue
# Install dependencies
COPY --from=build-libplist /build /
# Build
RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libimobiledevice-glue
# libtatsu
# --------
FROM build-base AS build-libtatsu
# Install dependencies
COPY --from=build-libplist /build /
# Build
RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libtatsu
# libusbmuxd
# ----------
FROM build-base AS build-libusbmuxd
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
# Build
RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libusbmuxd
# libimobiledevice
# ----------------
FROM build-base AS build-libimobiledevice
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libtatsu /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libusbmuxd /build /
# Build
RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \
&& ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libimobiledevice
# usbmuxd
# -------
FROM build-base AS build-usbmuxd
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libusbmuxd /build /
COPY --from=build-libimobiledevice /build /
# Build
RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
&& ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib
# Create main image
FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
@@ -112,7 +8,7 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
LABEL org.opencontainers.image.title="Mobile Verification Toolkit"
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
LABEL org.opencontainers.image.licenses="MVT License 1.1"
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:22.04
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04
# Install runtime dependencies
ARG DEBIAN_FRONTEND=noninteractive
@@ -125,7 +21,6 @@ RUN apt-get update \
git \
jq \
less \
libcurl4 \
libimage-exiftool-perl \
libssl3 \
libusb-1.0-0 \
@@ -136,18 +31,20 @@ RUN apt-get update \
sqlite3 \
tree \
unzip \
xxd
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libtatsu /build /
COPY --from=build-libusbmuxd /build /
COPY --from=build-libimobiledevice /build /
COPY --from=build-usbmuxd /build /
xxd \
&& rm -rf /var/lib/apt/lists/*
# Install mvt using the locally checked out source
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
ARG PYMOBILEDEVICE3_VERSION=11.12.4
COPY . mvt/
RUN uv pip install --system --break-system-packages --no-cache ./mvt \
&& rm -rf mvt
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \
&& uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \
&& apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \
&& rm -rf mvt /var/lib/apt/lists/* \
&& pymobiledevice3 --help > /dev/null \
&& pymobiledevice3 backup2 --help > /dev/null \
&& mvt-ios --help > /dev/null
# Installing ABE
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
+22 -122
View File
@@ -1,110 +1,6 @@
# Base image for building libraries
# ---------------------------------
FROM ubuntu:22.04 AS build-base
ARG DEBIAN_FRONTEND=noninteractive
# Install build tools and dependencies
RUN apt-get update \
&& apt-get install -y \
build-essential \
git \
autoconf \
automake \
libtool-bin \
pkg-config \
libcurl4-openssl-dev \
libusb-1.0-0-dev \
libssl-dev \
udev \
&& rm -rf /var/lib/apt/lists/*
# libplist
# --------
FROM build-base AS build-libplist
# Build
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libplist
# libimobiledevice-glue
# ---------------------
FROM build-base AS build-libimobiledevice-glue
# Install dependencies
COPY --from=build-libplist /build /
# Build
RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libimobiledevice-glue
# libtatsu
# --------
FROM build-base AS build-libtatsu
# Install dependencies
COPY --from=build-libplist /build /
# Build
RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libtatsu
# libusbmuxd
# ----------
FROM build-base AS build-libusbmuxd
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
# Build
RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libusbmuxd
# libimobiledevice
# ----------------
FROM build-base AS build-libimobiledevice
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libtatsu /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libusbmuxd /build /
# Build
RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \
&& ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libimobiledevice
# usbmuxd
# -------
FROM build-base AS build-usbmuxd
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libusbmuxd /build /
COPY --from=build-libimobiledevice /build /
# Build
RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
&& ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib
# Main image
# ----------
FROM python:3.10.14-alpine3.20 AS main
FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re"
@@ -113,26 +9,30 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
LABEL org.opencontainers.image.title="Mobile Verification Toolkit (iOS)"
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
LABEL org.opencontainers.image.licenses="MVT License 1.1"
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.10.14-alpine3.20
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04
# Install runtime dependencies
RUN apk add --no-cache \
gcompat \
libcurl \
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
libssl3 \
libusb \
sqlite
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libtatsu /build /
COPY --from=build-libusbmuxd /build /
COPY --from=build-libimobiledevice /build /
COPY --from=build-usbmuxd /build /
libusb-1.0-0 \
python3 \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
# Install mvt using the locally checked out source
COPY ./ mvt
RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \
&& uv pip install --system --no-cache ./mvt \
&& apk del .build-deps git gcc musl-dev && rm -rf ./mvt
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
ARG PYMOBILEDEVICE3_VERSION=11.12.4
COPY . mvt/
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \
&& uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \
&& apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \
&& rm -rf mvt /var/lib/apt/lists/* \
&& pymobiledevice3 --help > /dev/null \
&& pymobiledevice3 backup2 --help > /dev/null \
&& mvt-ios --help > /dev/null
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
-1
View File
@@ -31,4 +31,3 @@ clean:
dist:
$(UV) build
+6
View File
@@ -389,3 +389,9 @@ MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
```
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
## Output schemas
Custom modules should declare an `output_model` using a Pydantic `RootModel`.
This validates saved results and lets consumers obtain the module's JSON Schema.
See [Output schemas](../output-schemas.md#custom-modules) for a complete example.
+24 -3
View File
@@ -1,4 +1,6 @@
Using Docker simplifies having all the required dependencies and tools (including most recent versions of [libimobiledevice](https://libimobiledevice.org)) readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container).
Using Docker simplifies running MVT with its dependencies readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container).
The main and iOS Docker images include pymobiledevice3 for [creating iOS backups](ios/backup/pymobiledevice3.md). You do not need to install it separately on the host. Older image tags may still include libimobiledevice instead; build from the updated source if pymobiledevice3 is unavailable.
Install Docker following the [official documentation](https://docs.docker.com/get-docker/).
@@ -37,12 +39,31 @@ If a prompt is spawned successfully, you can close it with `exit`.
On the Linux host, install and start [usbmuxd](https://github.com/libimobiledevice/usbmuxd), then connect and unlock the iOS device. The daemon exposes the device through the `/var/run/usbmuxd` socket.
Bind that socket into the container to let MVT communicate with the device:
Bind that socket into the container to let pymobiledevice3 communicate with the device. Also mount a local directory so acquired backups persist after the container exits:
```bash
docker run -it \
mkdir -p "$PWD/cases"
docker run --rm -it \
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
--mount type=bind,source="$PWD/cases",target=/home/cases \
ghcr.io/mvt-project/mvt
```
Inside the container, verify connectivity:
```bash
pymobiledevice3 lockdown info
```
Accept the trust prompt on the unlocked device if requested. Then follow the [backup instructions](ios/backup/pymobiledevice3.md), using a destination under `/home/cases`, such as `/home/cases/backup`.
The iOS-only image defaults to running `mvt-ios`. To use pymobiledevice3 instead, override its entrypoint:
```bash
docker run --rm -it \
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
--entrypoint pymobiledevice3 \
ghcr.io/mvt-project/mvt:latest-ios lockdown info
```
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
+1 -17
View File
@@ -1,19 +1,3 @@
# Backup with libimobiledevice
If you have correctly [installed libimobiledevice](../install.md) you can easily generate an iTunes backup using the `idevicebackup2` tool included in the suite. First, you might want to ensure that backup encryption is enabled (**note: encrypted backup contain more data than unencrypted backups**):
```bash
idevicebackup2 -i encryption on
```
Note that if a backup password was previously set on this device, you might need to use the same or change it. You can try changing password using `idevicebackup2 -i changepw`, or by turning off encryption (`idevicebackup2 -i encryption off`) and turning it back on again.
If you are not able to recover or change the password, you should try to disable encryption and obtain an unencrypted backup.
If all else fails, as a *last resort* you can try resetting the password by [resetting all the settings through the iPhone's Settings app](https://support.apple.com/en-us/HT205220), via `Settings » General » Reset » Reset All Settings`. Note that resetting the settings through the iPhone's Settings app will wipe some of the files that contain useful forensic traces, so try the options explained above first.
Once ready, you can proceed performing the backup:
```bash
idevicebackup2 backup --full /path/to/backup/
```
We now recommend pymobiledevice3 for iOS backups. See [Backup with pymobiledevice3](pymobiledevice3.md) for the current instructions.
+37
View File
@@ -0,0 +1,37 @@
# Backup with pymobiledevice3
After [installing pymobiledevice3](../install.md) and pairing your device, you can generate an iTunes-compatible backup using its `backup2` commands.
## Enable backup encryption
We recommend encrypted backups because they contain more data than unencrypted backups. If encryption is not already enabled, enable it with:
```bash
pymobiledevice3 backup2 encryption on 'YOUR_BACKUP_PASSWORD'
```
Replace `YOUR_BACKUP_PASSWORD` with a strong password and keep it safe: you will need it to decrypt the backup for analysis. Passwords supplied on the command line may be saved in shell history or visible to other processes.
If a backup password was previously set, use that password. To change a known password:
```bash
pymobiledevice3 backup2 change-password 'CURRENT_PASSWORD' 'NEW_PASSWORD'
```
You can also disable encryption with `pymobiledevice3 backup2 encryption off 'CURRENT_PASSWORD'` and then enable it again. Disabling encryption requires the current password; it is not a way to bypass an unknown password.
!!! warning
If you cannot recover the password, resetting it through [Reset All Settings in the iPhone's Settings app](https://support.apple.com/en-us/HT205220) should be a last resort. Resetting settings can remove files containing useful forensic traces.
## Create a backup
Choose a new destination directory to avoid overwriting an earlier acquisition:
```bash
mkdir -p /path/to/backup/
pymobiledevice3 backup2 backup --full /path/to/backup/
```
The backup is saved in a subdirectory named after the device's UDID, such as `/path/to/backup/udid/`. Follow [Check a Backup with mvt-ios](check.md) to decrypt and analyze that directory.
For additional options, see the [pymobiledevice3 backup2 reference](https://doronz88.github.io/pymobiledevice3/cli/backup2/).
+20 -31
View File
@@ -1,55 +1,44 @@
# Install libimobiledevice
# Install pymobiledevice3
Before proceeding with doing any acquisition of iOS devices we recommend installing [libimobiledevice](https://libimobiledevice.org/) utilities. These utilities will become useful when extracting crash logs and generating iTunes backups. Because the utilities and its libraries are subject to frequent changes in response to new versions of iOS, you might want to consider compiling libimobiledevice utilities from sources. Otherwise, if available, you can try installing packages available in your distribution:
Before acquiring data from an iOS device, we recommend installing [pymobiledevice3](https://github.com/doronz88/pymobiledevice3). It provides command-line tools for generating iTunes-compatible backups and extracting crash logs.
## Installation
Install `pipx` following the [MVT installation instructions](../install.md), then run:
```bash
sudo apt install libimobiledevice-utils
pipx install pymobiledevice3
```
On Mac, you can try installing it from brew:
To update an existing installation:
```bash
brew install --HEAD libimobiledevice
pipx upgrade pymobiledevice3
```
If you have a reasonably recent version of libimobiledevice in your package manager, it might work straight out of the box. Try connecting your iOS device to your computer via USB and run:
On Linux, you also need `usbmuxd` to communicate with devices over USB. On Debian and Ubuntu, install it with:
```bash
ideviceinfo
sudo apt install usbmuxd
```
If you encounter unexpected issues, uninstall the packages and try compiling libimobiledevcice from sources.
macOS includes the required USB device service. For other platforms and additional requirements, see the [pymobiledevice3 installation documentation](https://doronz88.github.io/pymobiledevice3/installation/).
## Compile libimobiledevice from sources
## Verify connectivity
!!! warning
The following instructions are a best effort. The installation from source requires several steps, and it is likely some have been forgotten here and that won't work for you. You will likely need to fiddle around a bit before getting this right.
Make sure you have uninstalled all the libimobiledevice tools from your package manage:
Connect the iOS device to your computer with a USB cable and unlock it. Accept the **Trust This Computer** prompt and enter the device passcode if requested. Then run:
```bash
sudo apt remove --purge libimobiledevice-utils libimobiledevice-dev libimobiledevice6 libplist-dev libplist3 libusbmuxd-dev libusbmuxd-tools libusbmuxd4 libusbmuxd6 usbmuxd
pymobiledevice3 usbmux list
pymobiledevice3 lockdown info
```
Firstly you need to install [libplist](https://github.com/libimobiledevice/libplist). Then you can install [libusbmuxd](https://github.com/libimobiledevice/libusbmuxd).
Now you should be able to to download and install the actual suite of tools at [https://github.com/libimobiledevice/libimobiledevice](https://github.com/libimobiledevice/libimobiledevice).
You can now also build and install [usbmuxd](https://github.com/libimobiledevice/usbmuxd).
## Making sure everything works fine.
Once the idevice tools are available you can check if everything works fine by connecting your iOS device and running:
These commands should list the connected device and display its details. If pairing is needed, run:
```bash
ideviceinfo
pymobiledevice3 lockdown pair
```
This should show many details on the connected iOS device. If you are connecting the device to your laptop for the first time, it will require to unlock and enter the PIN code on the mobile device. If it complains that no device is connected and the mobile device is indeed plugged in through the USB cable, you might need to do this first, although typically the pairing is automatically done when connecting the device:
If no device is found, check the USB connection, make sure the device is unlocked, and, on Linux, check that `usbmuxd` is running. See the [upstream troubleshooting guide](https://doronz88.github.io/pymobiledevice3/guides/troubleshooting/) for further help.
```bash
sudo usbmuxd -f -v
idevicepair pair
```
Again, it will ask to unlock the phone and enter the PIN code.
Once connected, follow the [backup instructions](backup/pymobiledevice3.md).
+77
View File
@@ -0,0 +1,77 @@
# Output schemas
MVT validates its JSON output with versioned Pydantic models. The schema version
used for a run is recorded as `output_schema_version` in `info.json`.
MVT preserves the established on-disk format: most module files contain an array
of result objects, while modules whose results are naturally grouped contain an
object keyed by source or namespace. Detection files and `alerts.json` contain
arrays of alert objects. Timestamps remain strings because their timezone and
precision depend on the source artifact.
## Exporting JSON Schema
Both platform commands can print a versioned JSON Schema bundle:
```bash
mvt-ios schemas
mvt-android schemas
```
Use `--output` to write one Draft 2020-12 JSON Schema file for each output:
```bash
mvt-ios schemas --output ./mvt-ios-schemas
mvt-android schemas --output ./mvt-android-schemas
```
## Python API
Models and schema discovery functions are available from `mvt.schemas`:
```python
from mvt.schemas import get_output_model
SafariHistoryOutput = get_output_model("safari_history", platform="ios")
validated = SafariHistoryOutput.model_validate(records)
json_schema = SafariHistoryOutput.model_json_schema()
```
Common outputs have dedicated field-level models. Built-in module outputs have a
declared root shape, and modules with an established dedicated record model expose
its complete field schema.
## Custom modules
Custom modules can publish a precise contract by assigning a Pydantic root model
to `output_model`:
```python
from pydantic import BaseModel, RootModel
from mvt.common.module import MVTModule
class ExampleRecord(BaseModel):
message: str
timestamp: str | None = None
class ExampleOutput(RootModel[list[ExampleRecord]]):
pass
class ExampleModule(MVTModule):
output_model = ExampleOutput
```
For compatibility, a custom module without `output_model` can still write an
array or object containing JSON values. MVT logs a warning when it uses this
generic contract. A future major release may require custom modules to declare
their output models.
## Compatibility policy
Within one output schema major version, required fields are not removed and field
types are not narrowed. New optional fields may be added. A breaking output
change requires a new schema major version and a migration note.
+4 -2
View File
@@ -5,6 +5,7 @@ copyright: Copyright © 2021-2023 MVT Project Developers
site_description: Mobile Verification Toolkit Documentation
not_in_nav: |
android/adb.md
ios/backup/libimobiledevice.md
markdown_extensions:
- attr_list
- admonition
@@ -31,13 +32,14 @@ nav:
- Introduction: "introduction.md"
- Installation: "install.md"
- Command Completion: "command_completion.md"
- Output Schemas: "output-schemas.md"
- Using Docker: "docker.md"
- MVT for iOS:
- iOS Forensic Methodology: "ios/methodology.md"
- Install libimobiledevice: "ios/install.md"
- Install pymobiledevice3: "ios/install.md"
- Check an iTunes Backup:
- Backup with iTunes app: "ios/backup/itunes.md"
- Backup with libimobiledevice: "ios/backup/libimobiledevice.md"
- Backup with pymobiledevice3: "ios/backup/pymobiledevice3.md"
- Check a Backup with mvt-ios: "ios/backup/check.md"
- Check a Filesystem Dump:
- Dumping the filesystem: "ios/filesystem/dump.md"
+9 -1
View File
@@ -33,7 +33,12 @@ class DumpsysADBArtifact(AndroidArtifact):
line = line.removesuffix(b"\r")
# Track the level of indentation
indent = len(line) - len(line.lstrip())
if indent < cur_indent:
# A closing brace is dedented from the block it closes, and the
# brace handler below pops that block. Popping on the dedent as
# well would close two levels for one brace. A blank line has no
# indentation to compare: it ends a multiline value (handled
# below) and is otherwise skipped.
if indent < cur_indent and line.strip() not in (b"", b"}"):
# If the current line is less indented than the previous one, back out
if len(stack) <= 1:
# Dedenting below the outermost level means this is not the
@@ -72,6 +77,9 @@ class DumpsysADBArtifact(AndroidArtifact):
stack.pop()
current_dict = stack[-1]
if not line.strip():
continue
if key == "}":
if len(stack) <= 1:
self.log.error(
+14 -8
View File
@@ -255,19 +255,25 @@ class Settings(AndroidArtifact):
dumpsys prints the change history without a year, so it is resolved
against the time the section was dumped: the most recent matching date
at or before that time.
The year is parsed together with the value, because strptime() would
otherwise parse it in 1900, which has no 29 February. The most recent
matching leap day can be eight years earlier when a century year is
not a leap year and the current year's leap day is still in the future.
"""
if section_end is None:
return None
try:
partial = datetime.strptime(value, "%m-%d %H:%M:%S.%f")
timestamp = partial.replace(year=section_end.year)
if timestamp > section_end:
timestamp = partial.replace(year=section_end.year - 1)
except ValueError:
return None
for year in range(section_end.year, section_end.year - 9, -1):
try:
timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f")
except ValueError:
continue
return convert_datetime_to_iso(timestamp)
if timestamp <= section_end:
return convert_datetime_to_iso(timestamp)
return None
def _parse_history(
self, line: str, section_end: Optional[datetime]
@@ -4,7 +4,7 @@
# https://license.mvt.re/1.1/
import datetime
from typing import List, Optional
from typing import ClassVar, List, Optional
import pydantic
import betterproto2
@@ -13,6 +13,7 @@ from dateutil import parser
from mvt.android.parsers.proto.tombstone import Tombstone
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.common.utils import convert_datetime_to_iso
from mvt.schemas import OutputModel
from .artifact import AndroidArtifact
@@ -73,6 +74,10 @@ class TombstoneCrashResult(pydantic.BaseModel):
extra: Optional[str] = None
class TombstoneCrashOutput(pydantic.RootModel[List[TombstoneCrashResult]]):
"""Complete JSON document written by tombstone crash modules."""
class TombstoneCrashArtifact(AndroidArtifact):
"""
Parser for Android tombstone crash files.
@@ -80,6 +85,8 @@ class TombstoneCrashArtifact(AndroidArtifact):
This parser can parse both text and protobuf tombstone crash files.
"""
output_model: ClassVar[OutputModel] = TombstoneCrashOutput
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
return {
"timestamp": record["timestamp"],
+21 -5
View File
@@ -98,12 +98,13 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
ctx.obj["verbose"] = verbose
set_verbose_logging(verbose)
from mvt.common.logo import logo
if ctx.invoked_subcommand not in ("completion", "schemas"):
from mvt.common.logo import logo
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ==============================================================================
@@ -115,6 +116,21 @@ def version():
# ==============================================================================
# Command: schemas
# ==============================================================================
@cli.command("schemas", help="Print or export the JSON Schemas for MVT outputs.")
@click.option(
"--output",
"-o",
type=click.Path(file_okay=False, dir_okay=True),
help="Write one JSON Schema file per output to this directory.",
)
def schemas(output):
from mvt.common.cmd_schemas import emit_schemas
emit_schemas("android", output)
# Command: check-adb (removed)
# ==============================================================================
@cli.command(
@@ -4,6 +4,7 @@
# https://license.mvt.re/1.1/
from mvt.android.artifacts.settings import Settings as SettingsArtifact
from mvt.schemas import RecordListOutput
from .base import AndroidQFModule
@@ -11,6 +12,8 @@ from .base import AndroidQFModule
class AQFSettings(SettingsArtifact, AndroidQFModule):
"""This module analyse setting files"""
output_model = RecordListOutput
def run(self) -> None:
for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
namespace = setting_file[setting_file.rfind("_") + 1 : -4]
+4 -1
View File
@@ -7,15 +7,18 @@ import fnmatch
import logging
import os
import zipfile
from typing import List, Optional
from typing import ClassVar, List, Optional
from mvt.common.module import MVTModule
from mvt.common.module_types import ModuleResults
from mvt.schemas import OutputModel, RecordListOutput
class AndroidQFModule(MVTModule):
"""This class provides a base for all Android Data analysis modules."""
output_model: ClassVar[OutputModel] = RecordListOutput
def __init__(
self,
file_path: Optional[str] = None,
+4 -1
View File
@@ -7,14 +7,17 @@ import fnmatch
import logging
import os
from tarfile import TarFile
from typing import List, Optional
from typing import ClassVar, List, Optional
from mvt.common.module import ModuleResults, MVTModule
from mvt.schemas import OutputModel, RecordListOutput
class BackupModule(MVTModule):
"""This class provides a base for all backup extractios modules"""
output_model: ClassVar[OutputModel] = RecordListOutput
def __init__(
self,
file_path: Optional[str] = None,
+4 -1
View File
@@ -8,11 +8,12 @@ import logging
import os
import re
from pathlib import Path
from typing import List, Optional
from typing import ClassVar, List, Optional
from zipfile import ZipFile
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from mvt.common.module import ModuleResults, MVTModule
from mvt.schemas import OutputModel, RecordListOutput
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
# section finishes and not necessarily between two sections.
@@ -27,6 +28,8 @@ DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
class BugReportModule(MVTModule):
"""This class provides a base for all Android Bug Report modules."""
output_model: ClassVar[OutputModel] = RecordListOutput
def __init__(
self,
file_path: Optional[str] = None,
@@ -8,6 +8,7 @@ from typing import Optional
from mvt.android.artifacts.dumpsys_receivers import DumpsysReceiversArtifact
from mvt.common.module_types import ModuleResults
from mvt.schemas import MappingOutput
from .base import BugReportModule
@@ -15,6 +16,8 @@ from .base import BugReportModule
class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
"""This module extracts details on receivers for risky activities."""
output_model = MappingOutput
def __init__(
self,
file_path: Optional[str] = None,
@@ -9,7 +9,7 @@ import json
import logging
import zipfile
from pathlib import Path
from typing import Optional, Union
from typing import ClassVar, Optional, Union
try:
import zoneinfo
@@ -18,6 +18,7 @@ except ImportError:
from mvt.common.module import MVTModule
from mvt.common.utils import convert_datetime_to_iso, convert_unix_to_iso
from mvt.schemas import OutputModel, RecordListOutput
class IntrusionLogsModule(MVTModule):
@@ -41,6 +42,8 @@ class IntrusionLogsModule(MVTModule):
original file-loading code path.
"""
output_model: ClassVar[OutputModel] = RecordListOutput
def __init__(
self,
file_path: Optional[str] = None,
+22
View File
@@ -0,0 +1,22 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Shared implementation for the platform schema-export commands."""
import json
from typing import Optional
import click
from mvt.schemas import export_json_schemas, schema_bundle
def emit_schemas(platform: str, output: Optional[str]) -> None:
if output:
paths = export_json_schemas(output, platform)
click.echo(f"Exported {len(paths)} {platform} output schemas to {output}")
return
click.echo(json.dumps(schema_bundle(platform), indent=2))
+13 -1
View File
@@ -34,6 +34,13 @@ from .utils import (
get_sha256_from_file_path,
)
from .version import MVT_VERSION
from mvt.schemas import (
OUTPUT_SCHEMA_VERSION,
AlertResults,
RunInfo,
URLResults,
validate_output,
)
class Command:
@@ -157,6 +164,8 @@ class Command:
if not alerts:
return
alerts = validate_output(AlertResults, alerts)
alerts_path = os.path.join(self.results_path, "alerts.json")
with open(alerts_path, "w+", encoding="utf-8") as handle:
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
@@ -165,9 +174,10 @@ class Command:
if not self.results_path or not self.url_results:
return
urls = validate_output(URLResults, self.url_results)
urls_path = os.path.join(self.results_path, "urls.json")
with open(urls_path, "w", encoding="utf-8") as handle:
json.dump(self.url_results, handle, indent=4, cls=CustomJSONEncoder)
json.dump(urls, handle, indent=4, cls=CustomJSONEncoder)
def _store_alerts_timeline(self) -> None:
if not self.results_path:
@@ -190,6 +200,7 @@ class Command:
"date": convert_datetime_to_iso(datetime.now()),
"ioc_files": [],
"hashes": [],
"output_schema_version": OUTPUT_SCHEMA_VERSION,
}
for coll in self.iocs.ioc_collections:
@@ -202,6 +213,7 @@ class Command:
info["hashes"] = self.hash_values
info = validate_output(RunInfo, info)
info_path = os.path.join(self.results_path, "info.json")
with open(info_path, "w+", encoding="utf-8") as handle:
json.dump(info, handle, indent=4)
+28 -24
View File
@@ -9,7 +9,12 @@ import logging
import os
import re
from dataclasses import asdict, is_dataclass
from typing import Any, Dict, Optional, Sequence
from typing import Any, ClassVar, Dict, Optional, Sequence
from pydantic import BaseModel, ValidationError
from mvt.schemas.models import AlertResults, GenericModuleOutput, TimelineResults
from mvt.schemas.serialization import validate_output
from .alerts import AlertStore
from .indicators import Indicators
@@ -46,6 +51,7 @@ class MVTModule:
slug: Optional[str] = None
dependencies: Sequence[type["MVTModule"]] = ()
supported_commands: Sequence[tuple[str, str]] = ()
output_model: ClassVar[type[BaseModel]] = GenericModuleOutput
# A custom module can name a module class it supersedes, usually a
# built-in one. When both are available to a command, the named module is
# dropped from the run and this module takes its place, including in the
@@ -100,7 +106,7 @@ class MVTModule:
@classmethod
def from_json(cls, json_path: str, log: logging.Logger):
with open(json_path, "r", encoding="utf-8") as handle:
results = json.load(handle)
results = validate_output(cls.output_model, json.load(handle))
if log:
log.info('Loaded %d results from "%s"', len(results), json_path)
@@ -141,38 +147,35 @@ class MVTModule:
name = self.get_slug()
if self.results:
converted_results: Any
if isinstance(self.results, dict):
converted_results = self.results
else:
converted_results = [
asdict(result)
if is_dataclass(result) and not isinstance(result, type)
else result
for result in self.results
]
results_file_name = f"{name}.json"
results_json_path = os.path.join(self.results_path, results_file_name)
with open(results_json_path, "w", encoding="utf-8") as handle:
try:
if self.output_model is GenericModuleOutput:
self.log.warning(
"Module %s does not declare an output_model; using the "
"generic compatibility schema",
self.__class__.__name__,
)
try:
converted_results = validate_output(self.output_model, self.results)
except (TypeError, ValueError, ValidationError) as exc:
self.log.error(
"Output from module %s does not match schema %s: %s",
self.__class__.__name__,
self.output_model.__name__,
exc,
)
else:
with open(results_json_path, "w", encoding="utf-8") as handle:
json.dump(
converted_results, handle, indent=4, cls=CustomJSONEncoder
)
except Exception as exc:
self.log.error(
"Unable to store results of module %s to file %s: %s",
self.__class__.__name__,
results_file_name,
exc,
)
if self.alertstore.alerts:
detected_file_name = f"{name}_detected.json"
detected_json_path = os.path.join(self.results_path, detected_file_name)
alerts = validate_output(AlertResults, self.alertstore.as_json())
with open(detected_json_path, "w", encoding="utf-8") as handle:
json.dump(
self.alertstore.as_json(), handle, indent=4, cls=CustomJSONEncoder
)
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult:
raise NotImplementedError
@@ -303,6 +306,7 @@ def save_timeline(timeline: list, timeline_path: str, is_utc: bool = True) -> No
:param timeline_path: Path to the csv file to store the timeline to
"""
timeline = validate_output(TimelineResults, timeline)
with open(timeline_path, "w", encoding="utf-8") as handle:
csvoutput = csv.writer(
handle, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL, escapechar="\\"
+21 -5
View File
@@ -110,12 +110,13 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
ctx.obj["verbose"] = verbose
set_verbose_logging(verbose)
from mvt.common.logo import logo
if ctx.invoked_subcommand not in ("completion", "schemas"):
from mvt.common.logo import logo
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ==============================================================================
@@ -127,6 +128,21 @@ def version():
# ==============================================================================
# Command: schemas
# ==============================================================================
@cli.command("schemas", help="Print or export the JSON Schemas for MVT outputs.")
@click.option(
"--output",
"-o",
type=click.Path(file_okay=False, dir_okay=True),
help="Write one JSON Schema file per output to this directory.",
)
def schemas(output):
from mvt.common.cmd_schemas import emit_schemas
emit_schemas("ios", output)
# Command: decrypt-backup
# ==============================================================================
@cli.command(
@@ -10,6 +10,7 @@ from typing import Optional
from mvt.common.module import DatabaseNotFoundError
from mvt.common.module_types import ModuleResults
from mvt.schemas import MappingOutput
from mvt.ios.versions import get_device_desc_from_id, is_ios_version_outdated
from ..base import IOSExtraction
@@ -18,6 +19,8 @@ from ..base import IOSExtraction
class BackupInfo(IOSExtraction):
"""This module extracts information about the device and the backup."""
output_model = MappingOutput
def __init__(
self,
file_path: Optional[str] = None,
+4 -1
View File
@@ -11,7 +11,7 @@ import sqlite3
import subprocess
import tempfile
from pathlib import Path
from typing import Iterator, Optional, Union
from typing import ClassVar, Iterator, Optional, Union
from mvt.common.module import (
DatabaseCorruptedError,
@@ -19,6 +19,7 @@ from mvt.common.module import (
ModuleResults,
MVTModule,
)
from mvt.schemas import OutputModel, RecordListOutput
class TemporarySQLiteConnection(sqlite3.Connection):
@@ -39,6 +40,8 @@ class IOSExtraction(MVTModule):
"""This class provides a base for all iOS filesystem/backup extraction
modules."""
output_model: ClassVar[OutputModel] = RecordListOutput
def __init__(
self,
file_path: Optional[str] = None,
+3
View File
@@ -13,11 +13,14 @@ from mvt.common.module_types import (
ModuleResults,
ModuleSerializedResult,
)
from mvt.schemas import MappingOutput
from ..base import IOSExtraction
class CacheFiles(IOSExtraction):
output_model = MappingOutput
def __init__(
self,
file_path: Optional[str] = None,
@@ -10,6 +10,7 @@ from typing import Optional
from mvt.common.module_types import ModuleResults
from mvt.common.utils import convert_datetime_to_iso
from mvt.schemas import MappingOutput
from ..base import IOSExtraction
@@ -32,6 +33,8 @@ class WebkitSessionResourceLog(IOSExtraction):
"""
output_model = MappingOutput
def __init__(
self,
file_path: Optional[str] = None,
+49
View File
@@ -0,0 +1,49 @@
"""Stable public API for MVT output models and JSON Schemas."""
from .models import (
OUTPUT_SCHEMA_VERSION,
AlertResult,
AlertResults,
FileHash,
GenericModuleOutput,
MappingOutput,
ModuleRecord,
OutputModel,
RecordListOutput,
RunInfo,
TimelineEvent,
TimelineResults,
URLResult,
URLResults,
)
from .registry import (
export_json_schemas,
get_output_model,
get_output_models,
schema_bundle,
)
from .serialization import json_compatible, validate_output, write_output
__all__ = [
"OUTPUT_SCHEMA_VERSION",
"AlertResult",
"AlertResults",
"FileHash",
"GenericModuleOutput",
"MappingOutput",
"ModuleRecord",
"OutputModel",
"RecordListOutput",
"RunInfo",
"TimelineEvent",
"TimelineResults",
"URLResult",
"URLResults",
"export_json_schemas",
"get_output_model",
"get_output_models",
"json_compatible",
"schema_bundle",
"validate_output",
"write_output",
]
+101
View File
@@ -0,0 +1,101 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Public Pydantic models for files written by MVT.
Module records intentionally remain extensible: each extraction module has its own
record fields, while the root models make the top-level output shape predictable.
More specific modules can replace :class:`ModuleRecord` with a dedicated model.
"""
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, JsonValue, RootModel
OUTPUT_SCHEMA_VERSION: Literal["1.0"] = "1.0"
OutputModel = type[BaseModel]
class MVTOutputModel(BaseModel):
"""Base for stable output objects with no undocumented fields."""
model_config = ConfigDict(extra="forbid", strict=True)
class ModuleRecord(BaseModel):
"""A JSON object produced by an extraction module.
Module-specific keys are retained and represented in JSON Schema through
``additionalProperties``. Dedicated module models can provide stronger field
contracts without changing the serialization machinery.
"""
model_config = ConfigDict(extra="allow", strict=True)
class RecordListOutput(RootModel[list[ModuleRecord]]):
"""The standard output shape for built-in extraction modules."""
class MappingOutput(RootModel[dict[str, JsonValue]]):
"""Output shape for modules grouped by a dynamic string key."""
class GenericModuleOutput(RootModel[list[JsonValue] | dict[str, JsonValue]]):
"""Compatibility output shape for third-party modules without a model."""
class FileHash(MVTOutputModel):
file_path: str
sha256: str
class RunInfo(MVTOutputModel):
target_path: Optional[str]
mvt_version: str
date: str
ioc_files: list[str]
hashes: list[FileHash]
output_schema_version: Literal["1.0"]
class URLResult(MVTOutputModel):
url: str
expanded_url: Optional[str]
timestamp: Optional[str]
source: str
class URLResults(RootModel[list[URLResult]]):
pass
AlertLevelName = Literal["INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"]
class AlertResult(MVTOutputModel):
level: AlertLevelName
module: str
message: str
event_time: str
event: dict[str, JsonValue]
matched_indicator: Optional[JsonValue] = None
class AlertResults(RootModel[list[AlertResult]]):
pass
class TimelineEvent(MVTOutputModel):
"""Public contract for the records used to create ``timeline.csv``."""
timestamp: Optional[str]
module: str
event: str
data: str
class TimelineResults(RootModel[list[TimelineEvent]]):
pass
+133
View File
@@ -0,0 +1,133 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Discovery and JSON Schema export for MVT output models."""
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Iterable
from pydantic import BaseModel
from .models import (
OUTPUT_SCHEMA_VERSION,
AlertResults,
RunInfo,
TimelineResults,
URLResults,
)
if TYPE_CHECKING:
from mvt.common.module import MVTModule
COMMON_OUTPUT_MODELS: dict[str, type[BaseModel]] = {
"alerts": AlertResults,
"info": RunInfo,
"timeline": TimelineResults,
"urls": URLResults,
}
JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
def _module_classes(platform: str) -> Iterable[type["MVTModule"]]:
if platform == "ios":
from mvt.ios.modules.backup import BACKUP_MODULES as IOS_BACKUP_MODULES
from mvt.ios.modules.fs import FS_MODULES
from mvt.ios.modules.mixed import MIXED_MODULES
return IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES
if platform == "android":
from mvt.android.modules.androidqf import ANDROIDQF_MODULES
from mvt.android.modules.backup import BACKUP_MODULES as ANDROID_BACKUP_MODULES
from mvt.android.modules.bugreport import BUGREPORT_MODULES
from mvt.android.modules.intrusion_logs import INTRUSION_LOGS_MODULES
return (
ANDROID_BACKUP_MODULES
+ BUGREPORT_MODULES
+ ANDROIDQF_MODULES
+ INTRUSION_LOGS_MODULES
)
raise ValueError(f"Unsupported MVT platform: {platform}")
def get_output_models(platform: str) -> dict[str, type[BaseModel]]:
"""Return common and module output models available for a platform."""
models = dict(COMMON_OUTPUT_MODELS)
for module in _module_classes(platform):
models[module.get_slug()] = module.output_model
return dict(sorted(models.items()))
def get_output_model(name: str, platform: str | None = None) -> type[BaseModel]:
"""Look up an output model by file stem/module slug.
``platform`` is optional for common outputs. It is required when an output
slug exists on both platforms and resolves to different models.
"""
normalized = name.removesuffix(".json")
if normalized.endswith("_detected"):
return AlertResults
if normalized in COMMON_OUTPUT_MODELS:
return COMMON_OUTPUT_MODELS[normalized]
if platform:
try:
return get_output_models(platform)[normalized]
except KeyError as exc:
raise KeyError(f"Unknown {platform} output schema: {name}") from exc
matches = {
models[normalized]
for candidate in ("ios", "android")
if normalized in (models := get_output_models(candidate))
}
if len(matches) == 1:
return matches.pop()
if len(matches) > 1:
raise KeyError(f"Output schema {name!r} is ambiguous; specify a platform")
raise KeyError(f"Unknown MVT output schema: {name}")
def schema_bundle(platform: str) -> dict[str, Any]:
"""Build a versioned bundle of Draft 2020-12 JSON Schemas."""
return {
"output_schema_version": OUTPUT_SCHEMA_VERSION,
"platform": platform,
"schemas": {
name: _output_json_schema(name, model, platform)
for name, model in get_output_models(platform).items()
},
}
def _output_json_schema(
name: str, model: type[BaseModel], platform: str
) -> dict[str, Any]:
schema = model.model_json_schema()
return {
"$schema": JSON_SCHEMA_DIALECT,
"$id": f"urn:mvt:output-schema:{OUTPUT_SCHEMA_VERSION}:{platform}:{name}",
**schema,
}
def export_json_schemas(destination: str | os.PathLike[str], platform: str) -> list[Path]:
"""Write one JSON Schema per output and return the paths created."""
destination_path = Path(destination)
destination_path.mkdir(parents=True, exist_ok=True)
written = []
for name, model in get_output_models(platform).items():
output_path = destination_path / f"{name}.schema.json"
with output_path.open("w", encoding="utf-8") as handle:
json.dump(_output_json_schema(name, model, platform), handle, indent=2)
handle.write("\n")
written.append(output_path)
return written
+35
View File
@@ -0,0 +1,35 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Validation and serialization helpers shared by all MVT output writers."""
import json
from pathlib import Path
from typing import Any
from pydantic import BaseModel
from mvt.common.utils import CustomJSONEncoder
def json_compatible(value: Any) -> Any:
"""Apply MVT's legacy conversions before validating JSON-native values."""
return json.loads(json.dumps(value, cls=CustomJSONEncoder))
def validate_output(model: type[BaseModel], value: Any) -> Any:
"""Validate an output document and return JSON-serializable Python values."""
compatible = json_compatible(value)
return model.model_validate(compatible).model_dump(mode="json")
def write_output(path: str | Path, model: type[BaseModel], value: Any) -> None:
"""Validate and write an MVT JSON document."""
validated = validate_output(model, value)
with open(path, "w", encoding="utf-8") as handle:
json.dump(validated, handle, indent=4)
@@ -209,6 +209,85 @@ class TestDumpsysADBArtifact:
assert len(da_adb.results) == 1
assert da_adb.results[0]["user_keys"][0]["user"] == user.decode()
def test_state_without_keys_is_parsed_without_error(self, caplog):
# Shape printed by every device with no stored ADB keys (Samsung
# SM-A525F, Android 12). The block's closing brace is dedented from
# the last entry and must close exactly one level.
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=false\n"
b" }\n"
b"}\n"
b"--------- 0.015s was the duration of dumpsys adb\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["connected_to_adb"] is False
assert not [r for r in caplog.records if r.levelname == "ERROR"]
def test_nested_block_as_last_entry_is_parsed_without_error(self, caplog):
# Shape printed by Pixel devices with no stored ADB keys.
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=false\n"
b" adb_wifi={\n"
b" enabled=false\n"
b" tls_port=0\n"
b" }\n"
b" }\n"
b"}\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["connected_to_adb"] is False
assert da_adb.results[0]["adb_wifi"]["tls_port"] == b"0"
assert not [r for r in caplog.records if r.levelname == "ERROR"]
def test_entry_after_nested_block_stays_in_debugging_manager(self):
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" adb_wifi={\n"
b" enabled=false\n"
b" }\n"
b" connected_to_adb=true\n"
b" }\n"
b"}\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["connected_to_adb"] is True
def test_blank_line_after_single_line_value_does_not_raise(self):
# `system_keys` is the content of /adb_keys and ends with a newline,
# so a blank line follows it. It is not a multiline field, so the
# blank line is not a multiline terminator either.
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=true\n"
b" user_keys=QUJDRA== host@example\n"
b"\n"
b" system_keys=RUZHSA== system@example\n"
b"\n"
b" }\n"
b"}\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["user_keys"][0]["user"] == "host@example"
assert da_adb.results[0]["system_keys"] == "RUZHSA== system@example"
def test_unbalanced_state_is_reported_rather_than_raising(self):
da_adb = DumpsysADBArtifact()
da_adb.parse(
+43
View File
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from typing import Optional
from mvt.android.artifacts.settings import Settings
from ..utils import get_artifact
@@ -21,6 +23,21 @@ def find(settings: Settings, name: str) -> list:
return [result for result in settings.results if result["name"] == name]
def resolve_history_time(time: str, section_end: str) -> Optional[str]:
settings = Settings()
settings.parse(
"SECURE SETTINGS (user 0)\n"
"_id:240 name:accessibility_enabled pkg:android value:1\n"
"\tHistory (accessibility_enabled)\n"
f"\t\ttime:{time} mode:update oldValue:0 newValue:1 "
"package:com.example.helper\n"
"\n"
"--------- 0.019s was the duration of dumpsys settings, "
f"ending at: {section_end}\n"
)
return settings.results[0]["history"][0]["timestamp"]
class TestSettingsArtifact:
def test_parsing(self):
settings = parse_bugreport_settings()
@@ -159,6 +176,32 @@ class TestSettingsArtifact:
}
]
def test_leap_day_history_keeps_its_timestamp(self):
assert (
resolve_history_time("02-29 22:41:07.980", "2024-03-05 23:14:28")
== "2024-02-29 22:41:07.980000"
)
def test_leap_day_history_resolved_to_the_last_leap_year(self):
# The most recent 29 February at or before the dump can be several
# years back when the dump was taken in a year without one.
assert (
resolve_history_time("02-29 22:41:07.980", "2025-03-01 10:00:00")
== "2024-02-29 22:41:07.980000"
)
assert (
resolve_history_time("02-29 22:41:07.980", "2024-01-10 10:00:00")
== "2020-02-29 22:41:07.980000"
)
def test_leap_day_history_before_a_leap_day_after_a_skipped_century(self):
# 2100 is not a leap year, so before 29 February 2104 the most
# recent matching date is eight years earlier, in 2096.
assert (
resolve_history_time("02-29 22:41:07.980", "2104-01-10 10:00:00")
== "2096-02-29 22:41:07.980000"
)
def test_dangerous_setting_is_detected_with_the_changing_package(self):
settings = parse_bugreport_settings()
settings.check_indicators()
+131
View File
@@ -0,0 +1,131 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import json
import logging
import pytest
from click.testing import CliRunner
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError
from mvt.android.cli import cli as android_cli
from mvt.common.module import MVTModule
from mvt.ios.cli import cli as ios_cli
from mvt.schemas import (
OUTPUT_SCHEMA_VERSION,
GenericModuleOutput,
MappingOutput,
RecordListOutput,
export_json_schemas,
get_output_model,
get_output_models,
schema_bundle,
validate_output,
)
class CountRecord(BaseModel):
model_config = ConfigDict(strict=True)
count: int
class CountOutput(RootModel[list[CountRecord]]):
pass
class CountModule(MVTModule):
output_model = CountOutput
def test_common_output_model_validates_without_changing_root_shape():
assert validate_output(RecordListOutput, [{"name": "example", "size": 2}]) == [
{"name": "example", "size": 2}
]
assert validate_output(MappingOutput, {"global": {"enabled": "1"}}) == {
"global": {"enabled": "1"}
}
def test_module_model_is_used_when_loading_existing_output(tmp_path):
path = tmp_path / "count.json"
path.write_text('[{"count": 4}]', encoding="utf-8")
module = CountModule.from_json(str(path), logging.getLogger(__name__))
assert module.results == [{"count": 4}]
def test_invalid_module_output_is_not_written(tmp_path, caplog):
module = CountModule(results_path=str(tmp_path), results=[{"count": "four"}])
with caplog.at_level(logging.ERROR):
module.save_to_json()
assert not (tmp_path / "count_module.json").exists()
assert "does not match schema CountOutput" in caplog.text
def test_invalid_existing_output_raises_validation_error(tmp_path):
path = tmp_path / "count.json"
path.write_text('[{"count": "four"}]', encoding="utf-8")
with pytest.raises(ValidationError):
CountModule.from_json(str(path), logging.getLogger(__name__))
@pytest.mark.parametrize("platform", ["ios", "android"])
def test_every_builtin_module_has_a_non_generic_output_model(platform):
module_models = {
name: model
for name, model in get_output_models(platform).items()
if name not in {"alerts", "info", "timeline", "urls"}
}
assert module_models
assert GenericModuleOutput not in module_models.values()
def test_known_mapping_and_specific_models_are_registered():
assert get_output_model("backup_info.json", "ios") is MappingOutput
assert get_output_model("dumpsys_receivers", "android") is MappingOutput
assert get_output_model("tombstones", "android").__name__ == "TombstoneCrashOutput"
assert get_output_model("sms_detected.json", "ios").__name__ == "AlertResults"
def test_schema_bundle_has_stable_versioned_shape():
bundle = schema_bundle("android")
assert bundle["output_schema_version"] == OUTPUT_SCHEMA_VERSION
assert bundle["platform"] == "android"
assert bundle["schemas"]["urls"]["$schema"].endswith("2020-12/schema")
assert bundle["schemas"]["urls"]["type"] == "array"
assert bundle["schemas"]["info"]["additionalProperties"] is False
assert bundle["schemas"]["dumpsys_receivers"]["type"] == "object"
def test_export_json_schemas_writes_one_file_per_registered_output(tmp_path):
paths = export_json_schemas(tmp_path, "android")
assert len(paths) == len(get_output_models("android"))
assert json.loads((tmp_path / "info.schema.json").read_text())["title"] == (
"RunInfo"
)
@pytest.mark.parametrize(
("cli", "platform"), [(ios_cli, "ios"), (android_cli, "android")]
)
def test_schema_cli_prints_machine_readable_bundle(cli, platform):
result = CliRunner().invoke(cli, ["schemas"])
assert result.exit_code == 0
assert json.loads(result.output)["platform"] == platform
def test_schema_cli_exports_schema_files(tmp_path):
result = CliRunner().invoke(android_cli, ["schemas", "--output", str(tmp_path)])
assert result.exit_code == 0
assert (tmp_path / "urls.schema.json").exists()