mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-24 08:08:25 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0258c990ff |
No files matched your search
+116
-13
@@ -1,3 +1,107 @@
|
||||
# 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/
|
||||
@@ -8,7 +112,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:24.04
|
||||
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:22.04
|
||||
|
||||
# Install runtime dependencies
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
@@ -21,6 +125,7 @@ RUN apt-get update \
|
||||
git \
|
||||
jq \
|
||||
less \
|
||||
libcurl4 \
|
||||
libimage-exiftool-perl \
|
||||
libssl3 \
|
||||
libusb-1.0-0 \
|
||||
@@ -31,20 +136,18 @@ RUN apt-get update \
|
||||
sqlite3 \
|
||||
tree \
|
||||
unzip \
|
||||
xxd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
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 /
|
||||
|
||||
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
|
||||
ARG PYMOBILEDEVICE3_VERSION=11.12.4
|
||||
# Install mvt using the locally checked out source
|
||||
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
|
||||
RUN uv pip install --system --break-system-packages --no-cache ./mvt \
|
||||
&& rm -rf mvt
|
||||
|
||||
# Installing ABE
|
||||
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
|
||||
|
||||
+122
-22
@@ -1,6 +1,110 @@
|
||||
# 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 ubuntu:24.04 AS main
|
||||
FROM python:3.10.14-alpine3.20 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"
|
||||
@@ -9,30 +113,26 @@ 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/ubuntu:24.04
|
||||
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.10.14-alpine3.20
|
||||
|
||||
# Install runtime dependencies
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
RUN apk add --no-cache \
|
||||
gcompat \
|
||||
libcurl \
|
||||
libssl3 \
|
||||
libusb-1.0-0 \
|
||||
python3 \
|
||||
sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
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 /
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
|
||||
@@ -389,9 +389,3 @@ MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
||||
```
|
||||
|
||||
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
|
||||
|
||||
## Output schemas
|
||||
|
||||
Custom modules should declare an `output_model` using a Pydantic `RootModel`.
|
||||
This validates saved results and lets consumers obtain the module's JSON Schema.
|
||||
See [Output schemas](../output-schemas.md#custom-modules) for a complete example.
|
||||
+3
-24
@@ -1,6 +1,4 @@
|
||||
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.
|
||||
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).
|
||||
|
||||
Install Docker following the [official documentation](https://docs.docker.com/get-docker/).
|
||||
|
||||
@@ -39,31 +37,12 @@ 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 pymobiledevice3 communicate with the device. Also mount a local directory so acquired backups persist after the container exits:
|
||||
Bind that socket into the container to let MVT communicate with the device:
|
||||
|
||||
```bash
|
||||
mkdir -p "$PWD/cases"
|
||||
docker run --rm -it \
|
||||
docker run -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
-1
@@ -1,6 +1,6 @@
|
||||
# Installation
|
||||
|
||||
Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
|
||||
Before proceeding, please note that MVT requires Python 3.6+ to run. While it should be available on most operating systems, please make sure of that before proceeding.
|
||||
|
||||
## Dependencies on Linux
|
||||
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
# Backup with libimobiledevice
|
||||
|
||||
We now recommend pymobiledevice3 for iOS backups. See [Backup with pymobiledevice3](pymobiledevice3.md) for the current instructions.
|
||||
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/
|
||||
```
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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/).
|
||||
+31
-20
@@ -1,44 +1,55 @@
|
||||
# Install pymobiledevice3
|
||||
# Install libimobiledevice
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
pipx install pymobiledevice3
|
||||
sudo apt install libimobiledevice-utils
|
||||
```
|
||||
|
||||
To update an existing installation:
|
||||
On Mac, you can try installing it from brew:
|
||||
|
||||
```bash
|
||||
pipx upgrade pymobiledevice3
|
||||
brew install --HEAD libimobiledevice
|
||||
```
|
||||
|
||||
On Linux, you also need `usbmuxd` to communicate with devices over USB. On Debian and Ubuntu, install it with:
|
||||
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:
|
||||
|
||||
```bash
|
||||
sudo apt install usbmuxd
|
||||
ideviceinfo
|
||||
```
|
||||
|
||||
macOS includes the required USB device service. For other platforms and additional requirements, see the [pymobiledevice3 installation documentation](https://doronz88.github.io/pymobiledevice3/installation/).
|
||||
If you encounter unexpected issues, uninstall the packages and try compiling libimobiledevcice from sources.
|
||||
|
||||
## Verify connectivity
|
||||
## Compile libimobiledevice from sources
|
||||
|
||||
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:
|
||||
!!! 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:
|
||||
|
||||
```bash
|
||||
pymobiledevice3 usbmux list
|
||||
pymobiledevice3 lockdown info
|
||||
sudo apt remove --purge libimobiledevice-utils libimobiledevice-dev libimobiledevice6 libplist-dev libplist3 libusbmuxd-dev libusbmuxd-tools libusbmuxd4 libusbmuxd6 usbmuxd
|
||||
```
|
||||
|
||||
These commands should list the connected device and display its details. If pairing is needed, run:
|
||||
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:
|
||||
|
||||
```bash
|
||||
pymobiledevice3 lockdown pair
|
||||
ideviceinfo
|
||||
```
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
Once connected, follow the [backup instructions](backup/pymobiledevice3.md).
|
||||
```bash
|
||||
sudo usbmuxd -f -v
|
||||
idevicepair pair
|
||||
```
|
||||
|
||||
Again, it will ask to unlock the phone and enter the PIN code.
|
||||
@@ -204,10 +204,6 @@ This JSON file is created by mvt-ios' `Manifest` module. The module extracts rec
|
||||
|
||||
If indicators are provided through the command-line, they are checked against the original relative path in case. In some cases, there might be records of files created containing a domain name in their name, for example in the case of browser cache folders. Any matches are stored in *manifest_detected.json*.
|
||||
|
||||
An incomplete backup still lists in its manifest the files it failed to acquire. Those records carry a `"missing": true` field, and the module reports how many of them it found. Use it to tell a module which returned nothing because the artifact was not acquired apart from one which found nothing on a device that never had it.
|
||||
|
||||
Files must be stored at their expected paths inside the backup folder. If the module cannot finish listing the backup files, it logs a warning and skips the missing-file check; the manifest metadata is still extracted.
|
||||
|
||||
---
|
||||
|
||||
### `os_analytics_ad_daily.json`
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Output schemas
|
||||
|
||||
MVT validates its JSON output with versioned Pydantic models. The schema version
|
||||
used for a run is recorded as `output_schema_version` in `info.json`.
|
||||
|
||||
MVT preserves the established on-disk format: most module files contain an array
|
||||
of result objects, while modules whose results are naturally grouped contain an
|
||||
object keyed by source or namespace. Detection files and `alerts.json` contain
|
||||
arrays of alert objects. Timestamps remain strings because their timezone and
|
||||
precision depend on the source artifact.
|
||||
|
||||
## Exporting JSON Schema
|
||||
|
||||
Both platform commands can print a versioned JSON Schema bundle:
|
||||
|
||||
```bash
|
||||
mvt-ios schemas
|
||||
mvt-android schemas
|
||||
```
|
||||
|
||||
Use `--output` to write one Draft 2020-12 JSON Schema file for each output:
|
||||
|
||||
```bash
|
||||
mvt-ios schemas --output ./mvt-ios-schemas
|
||||
mvt-android schemas --output ./mvt-android-schemas
|
||||
```
|
||||
|
||||
## Python API
|
||||
|
||||
Models and schema discovery functions are available from `mvt.schemas`:
|
||||
|
||||
```python
|
||||
from mvt.schemas import get_output_model
|
||||
|
||||
SafariHistoryOutput = get_output_model("safari_history", platform="ios")
|
||||
validated = SafariHistoryOutput.model_validate(records)
|
||||
json_schema = SafariHistoryOutput.model_json_schema()
|
||||
```
|
||||
|
||||
Common outputs have dedicated field-level models. Built-in module outputs have a
|
||||
declared root shape, and modules with an established dedicated record model expose
|
||||
its complete field schema.
|
||||
|
||||
## Custom modules
|
||||
|
||||
Custom modules can publish a precise contract by assigning a Pydantic root model
|
||||
to `output_model`:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, RootModel
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
class ExampleRecord(BaseModel):
|
||||
message: str
|
||||
timestamp: str | None = None
|
||||
|
||||
|
||||
class ExampleOutput(RootModel[list[ExampleRecord]]):
|
||||
pass
|
||||
|
||||
|
||||
class ExampleModule(MVTModule):
|
||||
output_model = ExampleOutput
|
||||
```
|
||||
|
||||
For compatibility, a custom module without `output_model` can still write an
|
||||
array or object containing JSON values. MVT logs a warning when it uses this
|
||||
generic contract. A future major release may require custom modules to declare
|
||||
their output models.
|
||||
|
||||
## Compatibility policy
|
||||
|
||||
Within one output schema major version, required fields are not removed and field
|
||||
types are not narrowed. New optional fields may be added. A breaking output
|
||||
change requires a new schema major version and a migration note.
|
||||
+2
-4
@@ -5,7 +5,6 @@ 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
|
||||
@@ -32,14 +31,13 @@ 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 pymobiledevice3: "ios/install.md"
|
||||
- Install libimobiledevice: "ios/install.md"
|
||||
- Check an iTunes Backup:
|
||||
- Backup with iTunes app: "ios/backup/itunes.md"
|
||||
- Backup with pymobiledevice3: "ios/backup/pymobiledevice3.md"
|
||||
- Backup with libimobiledevice: "ios/backup/libimobiledevice.md"
|
||||
- Check a Backup with mvt-ios: "ios/backup/check.md"
|
||||
- Check a Filesystem Dump:
|
||||
- Dumping the filesystem: "ios/filesystem/dump.md"
|
||||
|
||||
@@ -33,12 +33,7 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
line = line.removesuffix(b"\r")
|
||||
# Track the level of indentation
|
||||
indent = len(line) - len(line.lstrip())
|
||||
# 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 indent < cur_indent:
|
||||
# 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
|
||||
@@ -77,9 +72,6 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
stack.pop()
|
||||
current_dict = stack[-1]
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
if key == "}":
|
||||
if len(stack) <= 1:
|
||||
self.log.error(
|
||||
|
||||
@@ -255,25 +255,19 @@ 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
if timestamp <= section_end:
|
||||
return convert_datetime_to_iso(timestamp)
|
||||
|
||||
return None
|
||||
return convert_datetime_to_iso(timestamp)
|
||||
|
||||
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 ClassVar, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
import pydantic
|
||||
import betterproto2
|
||||
@@ -13,7 +13,6 @@ from dateutil import parser
|
||||
from mvt.android.parsers.proto.tombstone import Tombstone
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from mvt.schemas import OutputModel
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
@@ -74,10 +73,6 @@ class TombstoneCrashResult(pydantic.BaseModel):
|
||||
extra: Optional[str] = None
|
||||
|
||||
|
||||
class TombstoneCrashOutput(pydantic.RootModel[List[TombstoneCrashResult]]):
|
||||
"""Complete JSON document written by tombstone crash modules."""
|
||||
|
||||
|
||||
class TombstoneCrashArtifact(AndroidArtifact):
|
||||
"""
|
||||
Parser for Android tombstone crash files.
|
||||
@@ -85,8 +80,6 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
This parser can parse both text and protobuf tombstone crash files.
|
||||
"""
|
||||
|
||||
output_model: ClassVar[OutputModel] = TombstoneCrashOutput
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
return {
|
||||
"timestamp": record["timestamp"],
|
||||
|
||||
+5
-21
@@ -98,13 +98,12 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
if ctx.invoked_subcommand not in ("completion", "schemas"):
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.logo import logo
|
||||
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -116,21 +115,6 @@ def version():
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: schemas
|
||||
# ==============================================================================
|
||||
@cli.command("schemas", help="Print or export the JSON Schemas for MVT outputs.")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(file_okay=False, dir_okay=True),
|
||||
help="Write one JSON Schema file per output to this directory.",
|
||||
)
|
||||
def schemas(output):
|
||||
from mvt.common.cmd_schemas import emit_schemas
|
||||
|
||||
emit_schemas("android", output)
|
||||
|
||||
|
||||
# Command: check-adb (removed)
|
||||
# ==============================================================================
|
||||
@cli.command(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
from mvt.schemas import RecordListOutput
|
||||
|
||||
from .base import AndroidQFModule
|
||||
|
||||
@@ -12,8 +11,6 @@ from .base import AndroidQFModule
|
||||
class AQFSettings(SettingsArtifact, AndroidQFModule):
|
||||
"""This module analyse setting files"""
|
||||
|
||||
output_model = RecordListOutput
|
||||
|
||||
def run(self) -> None:
|
||||
for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
|
||||
namespace = setting_file[setting_file.rfind("_") + 1 : -4]
|
||||
|
||||
@@ -7,18 +7,15 @@ import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import zipfile
|
||||
from typing import ClassVar, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
|
||||
class AndroidQFModule(MVTModule):
|
||||
"""This class provides a base for all Android Data analysis modules."""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -7,17 +7,14 @@ import fnmatch
|
||||
import logging
|
||||
import os
|
||||
from tarfile import TarFile
|
||||
from typing import ClassVar, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
|
||||
class BackupModule(MVTModule):
|
||||
"""This class provides a base for all backup extractios modules"""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -8,12 +8,11 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, List, Optional
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
|
||||
# section finishes and not necessarily between two sections.
|
||||
@@ -28,8 +27,6 @@ DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
|
||||
class BugReportModule(MVTModule):
|
||||
"""This class provides a base for all Android Bug Report modules."""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -48,12 +48,17 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
||||
)
|
||||
self.parse(content)
|
||||
|
||||
listed = unnamed = 0
|
||||
listed = unnamed = enabled_count = 0
|
||||
for result in self.results:
|
||||
if result.get("component"):
|
||||
listed += 1
|
||||
enabled = result.get("enabled")
|
||||
if enabled is True:
|
||||
enabled_count += 1
|
||||
self.log.info(
|
||||
'Found accessibility service "%s"', result.get("component")
|
||||
'Found accessibility service "%s" (enabled: %s)',
|
||||
result.get("component"),
|
||||
"not stated" if enabled is None else enabled,
|
||||
)
|
||||
continue
|
||||
# The operator gets this per user as a LOW alert from
|
||||
@@ -62,8 +67,9 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
||||
unnamed += result.get("unnamed_service_count") or 0
|
||||
|
||||
self.log.info(
|
||||
"Identified a total of %d accessibility services (%d more stated by "
|
||||
"the dump without a component name)",
|
||||
"Identified a total of %d accessibility services, %d reported enabled "
|
||||
"(%d more stated by the dump without a component name)",
|
||||
listed,
|
||||
enabled_count,
|
||||
unnamed,
|
||||
)
|
||||
@@ -8,7 +8,6 @@ from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.dumpsys_receivers import DumpsysReceiversArtifact
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.schemas import MappingOutput
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
@@ -16,8 +15,6 @@ from .base import BugReportModule
|
||||
class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
"""This module extracts details on receivers for risky activities."""
|
||||
|
||||
output_model = MappingOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -9,7 +9,7 @@ import json
|
||||
import logging
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Optional, Union
|
||||
from typing import Optional, Union
|
||||
|
||||
try:
|
||||
import zoneinfo
|
||||
@@ -18,7 +18,6 @@ except ImportError:
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.common.utils import convert_datetime_to_iso, convert_unix_to_iso
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
|
||||
class IntrusionLogsModule(MVTModule):
|
||||
@@ -42,8 +41,6 @@ class IntrusionLogsModule(MVTModule):
|
||||
original file-loading code path.
|
||||
"""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Shared implementation for the platform schema-export commands."""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from mvt.schemas import export_json_schemas, schema_bundle
|
||||
|
||||
|
||||
def emit_schemas(platform: str, output: Optional[str]) -> None:
|
||||
if output:
|
||||
paths = export_json_schemas(output, platform)
|
||||
click.echo(f"Exported {len(paths)} {platform} output schemas to {output}")
|
||||
return
|
||||
|
||||
click.echo(json.dumps(schema_bundle(platform), indent=2))
|
||||
@@ -34,13 +34,6 @@ from .utils import (
|
||||
get_sha256_from_file_path,
|
||||
)
|
||||
from .version import MVT_VERSION
|
||||
from mvt.schemas import (
|
||||
OUTPUT_SCHEMA_VERSION,
|
||||
AlertResults,
|
||||
RunInfo,
|
||||
URLResults,
|
||||
validate_output,
|
||||
)
|
||||
|
||||
|
||||
class Command:
|
||||
@@ -164,8 +157,6 @@ class Command:
|
||||
if not alerts:
|
||||
return
|
||||
|
||||
alerts = validate_output(AlertResults, alerts)
|
||||
|
||||
alerts_path = os.path.join(self.results_path, "alerts.json")
|
||||
with open(alerts_path, "w+", encoding="utf-8") as handle:
|
||||
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
|
||||
@@ -174,10 +165,9 @@ class Command:
|
||||
if not self.results_path or not self.url_results:
|
||||
return
|
||||
|
||||
urls = validate_output(URLResults, self.url_results)
|
||||
urls_path = os.path.join(self.results_path, "urls.json")
|
||||
with open(urls_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(urls, handle, indent=4, cls=CustomJSONEncoder)
|
||||
json.dump(self.url_results, handle, indent=4, cls=CustomJSONEncoder)
|
||||
|
||||
def _store_alerts_timeline(self) -> None:
|
||||
if not self.results_path:
|
||||
@@ -200,7 +190,6 @@ class Command:
|
||||
"date": convert_datetime_to_iso(datetime.now()),
|
||||
"ioc_files": [],
|
||||
"hashes": [],
|
||||
"output_schema_version": OUTPUT_SCHEMA_VERSION,
|
||||
}
|
||||
|
||||
for coll in self.iocs.ioc_collections:
|
||||
@@ -213,7 +202,6 @@ class Command:
|
||||
|
||||
info["hashes"] = self.hash_values
|
||||
|
||||
info = validate_output(RunInfo, info)
|
||||
info_path = os.path.join(self.results_path, "info.json")
|
||||
with open(info_path, "w+", encoding="utf-8") as handle:
|
||||
json.dump(info, handle, indent=4)
|
||||
|
||||
+24
-28
@@ -9,12 +9,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, ClassVar, Dict, Optional, Sequence
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from mvt.schemas.models import AlertResults, GenericModuleOutput, TimelineResults
|
||||
from mvt.schemas.serialization import validate_output
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from .alerts import AlertStore
|
||||
from .indicators import Indicators
|
||||
@@ -51,7 +46,6 @@ class MVTModule:
|
||||
slug: Optional[str] = None
|
||||
dependencies: Sequence[type["MVTModule"]] = ()
|
||||
supported_commands: Sequence[tuple[str, str]] = ()
|
||||
output_model: ClassVar[type[BaseModel]] = GenericModuleOutput
|
||||
# A custom module can name a module class it supersedes, usually a
|
||||
# built-in one. When both are available to a command, the named module is
|
||||
# dropped from the run and this module takes its place, including in the
|
||||
@@ -106,7 +100,7 @@ class MVTModule:
|
||||
@classmethod
|
||||
def from_json(cls, json_path: str, log: logging.Logger):
|
||||
with open(json_path, "r", encoding="utf-8") as handle:
|
||||
results = validate_output(cls.output_model, json.load(handle))
|
||||
results = json.load(handle)
|
||||
if log:
|
||||
log.info('Loaded %d results from "%s"', len(results), json_path)
|
||||
|
||||
@@ -147,35 +141,38 @@ class MVTModule:
|
||||
name = self.get_slug()
|
||||
|
||||
if self.results:
|
||||
converted_results: Any
|
||||
if isinstance(self.results, dict):
|
||||
converted_results = self.results
|
||||
else:
|
||||
converted_results = [
|
||||
asdict(result)
|
||||
if is_dataclass(result) and not isinstance(result, type)
|
||||
else result
|
||||
for result in self.results
|
||||
]
|
||||
results_file_name = f"{name}.json"
|
||||
results_json_path = os.path.join(self.results_path, results_file_name)
|
||||
if self.output_model is GenericModuleOutput:
|
||||
self.log.warning(
|
||||
"Module %s does not declare an output_model; using the "
|
||||
"generic compatibility schema",
|
||||
self.__class__.__name__,
|
||||
)
|
||||
try:
|
||||
converted_results = validate_output(self.output_model, self.results)
|
||||
except (TypeError, ValueError, ValidationError) as exc:
|
||||
self.log.error(
|
||||
"Output from module %s does not match schema %s: %s",
|
||||
self.__class__.__name__,
|
||||
self.output_model.__name__,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
with open(results_json_path, "w", encoding="utf-8") as handle:
|
||||
with open(results_json_path, "w", encoding="utf-8") as handle:
|
||||
try:
|
||||
json.dump(
|
||||
converted_results, handle, indent=4, cls=CustomJSONEncoder
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.error(
|
||||
"Unable to store results of module %s to file %s: %s",
|
||||
self.__class__.__name__,
|
||||
results_file_name,
|
||||
exc,
|
||||
)
|
||||
|
||||
if self.alertstore.alerts:
|
||||
detected_file_name = f"{name}_detected.json"
|
||||
detected_json_path = os.path.join(self.results_path, detected_file_name)
|
||||
alerts = validate_output(AlertResults, self.alertstore.as_json())
|
||||
with open(detected_json_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
|
||||
json.dump(
|
||||
self.alertstore.as_json(), handle, indent=4, cls=CustomJSONEncoder
|
||||
)
|
||||
|
||||
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
raise NotImplementedError
|
||||
@@ -306,7 +303,6 @@ def save_timeline(timeline: list, timeline_path: str, is_utc: bool = True) -> No
|
||||
:param timeline_path: Path to the csv file to store the timeline to
|
||||
|
||||
"""
|
||||
timeline = validate_output(TimelineResults, timeline)
|
||||
with open(timeline_path, "w", encoding="utf-8") as handle:
|
||||
csvoutput = csv.writer(
|
||||
handle, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL, escapechar="\\"
|
||||
|
||||
+5
-21
@@ -110,13 +110,12 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
if ctx.invoked_subcommand not in ("completion", "schemas"):
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.logo import logo
|
||||
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -128,21 +127,6 @@ def version():
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: schemas
|
||||
# ==============================================================================
|
||||
@cli.command("schemas", help="Print or export the JSON Schemas for MVT outputs.")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(file_okay=False, dir_okay=True),
|
||||
help="Write one JSON Schema file per output to this directory.",
|
||||
)
|
||||
def schemas(output):
|
||||
from mvt.common.cmd_schemas import emit_schemas
|
||||
|
||||
emit_schemas("ios", output)
|
||||
|
||||
|
||||
# Command: decrypt-backup
|
||||
# ==============================================================================
|
||||
@cli.command(
|
||||
|
||||
@@ -1279,9 +1279,5 @@
|
||||
{
|
||||
"version": "27.0",
|
||||
"build": "24A437"
|
||||
},
|
||||
{
|
||||
"version": "27.0.1",
|
||||
"build": "24A446"
|
||||
}
|
||||
]
|
||||
@@ -10,7 +10,6 @@ from typing import Optional
|
||||
|
||||
from mvt.common.module import DatabaseNotFoundError
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.schemas import MappingOutput
|
||||
from mvt.ios.versions import get_device_desc_from_id, is_ios_version_outdated
|
||||
|
||||
from ..base import IOSExtraction
|
||||
@@ -19,8 +18,6 @@ from ..base import IOSExtraction
|
||||
class BackupInfo(IOSExtraction):
|
||||
"""This module extracts information about the device and the backup."""
|
||||
|
||||
output_model = MappingOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -147,12 +147,6 @@ class Manifest(IOSExtraction):
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
# An incomplete backup still lists the files it failed to acquire in
|
||||
# its manifest. Only regular files are stored in the backup folder, so
|
||||
# directories and symlinks (flags 2 and 4) are not looked up.
|
||||
stored_file_ids = self._get_stored_backup_file_ids()
|
||||
missing_files = 0
|
||||
|
||||
for file_entry in cur:
|
||||
file_data = {}
|
||||
for index, value in enumerate(file_entry):
|
||||
@@ -166,22 +160,6 @@ class Manifest(IOSExtraction):
|
||||
"created": "",
|
||||
}
|
||||
|
||||
if (
|
||||
stored_file_ids is not None
|
||||
and file_data["flags"] == 1
|
||||
and file_data["fileID"] not in stored_file_ids
|
||||
):
|
||||
# Without this, a module which found nothing for one of these
|
||||
# files would look like a negative result rather than a gap in
|
||||
# the acquisition.
|
||||
cleaned_metadata["missing"] = True
|
||||
missing_files += 1
|
||||
self.log.debug(
|
||||
"File %s is listed in the manifest but was not found in the "
|
||||
"backup folder",
|
||||
cleaned_metadata["relative_path"],
|
||||
)
|
||||
|
||||
if file_data["file"]:
|
||||
try:
|
||||
file_plist = plistlib.load(io.BytesIO(file_data["file"]))
|
||||
@@ -219,10 +197,3 @@ class Manifest(IOSExtraction):
|
||||
conn.close()
|
||||
|
||||
self.log.info("Extracted a total of %d file metadata items", len(self.results))
|
||||
|
||||
if missing_files:
|
||||
self.log.info(
|
||||
"Found %d files listed in the manifest but missing from the backup "
|
||||
"folder. The backup might be incomplete.",
|
||||
missing_files,
|
||||
)
|
||||
@@ -11,7 +11,7 @@ import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Iterator, Optional, Union
|
||||
from typing import Iterator, Optional, Union
|
||||
|
||||
from mvt.common.module import (
|
||||
DatabaseCorruptedError,
|
||||
@@ -19,7 +19,6 @@ from mvt.common.module import (
|
||||
ModuleResults,
|
||||
MVTModule,
|
||||
)
|
||||
from mvt.schemas import OutputModel, RecordListOutput
|
||||
|
||||
|
||||
class TemporarySQLiteConnection(sqlite3.Connection):
|
||||
@@ -40,8 +39,6 @@ class IOSExtraction(MVTModule):
|
||||
"""This class provides a base for all iOS filesystem/backup extraction
|
||||
modules."""
|
||||
|
||||
output_model: ClassVar[OutputModel] = RecordListOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
@@ -219,55 +216,6 @@ class IOSExtraction(MVTModule):
|
||||
|
||||
return None
|
||||
|
||||
def _get_stored_backup_file_ids(self) -> Optional[set[str]]:
|
||||
"""List the IDs of the files actually stored in the backup folder.
|
||||
|
||||
A backup folder stores each file under a two character subfolder named
|
||||
after the first two characters of its file ID. Walking the folders once
|
||||
is cheaper than a filesystem lookup per file ID, and it keeps callers
|
||||
which compare a whole manifest against the folder from paying a
|
||||
`resolve()` for every entry.
|
||||
|
||||
:returns: The file IDs found at their expected paths within the backup
|
||||
folder, or None if the inventory could not be completed.
|
||||
"""
|
||||
if not self.target_path:
|
||||
return None
|
||||
|
||||
file_ids: set[str] = set()
|
||||
try:
|
||||
backup_root = Path(self.target_path).resolve()
|
||||
with os.scandir(self.target_path) as entries:
|
||||
for entry in entries:
|
||||
if len(entry.name) != 2 or any(
|
||||
char not in "0123456789abcdef" for char in entry.name
|
||||
):
|
||||
continue
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if not Path(entry.path).resolve().is_relative_to(backup_root):
|
||||
continue
|
||||
with os.scandir(entry.path) as sub_entries:
|
||||
for sub_entry in sub_entries:
|
||||
if sub_entry.name[:2] != entry.name:
|
||||
continue
|
||||
if sub_entry.is_symlink() and not Path(
|
||||
sub_entry.path
|
||||
).resolve().is_relative_to(backup_root):
|
||||
continue
|
||||
if sub_entry.is_file():
|
||||
file_ids.add(sub_entry.name)
|
||||
except OSError as exc:
|
||||
self.log.warning(
|
||||
"Unable to list the files stored in the backup folder %s: %s. "
|
||||
"Skipping the missing-file check.",
|
||||
self.target_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
return file_ids
|
||||
|
||||
def _get_fs_files_from_patterns(self, root_paths: list) -> Iterator[str]:
|
||||
if not self.target_path:
|
||||
return
|
||||
|
||||
@@ -13,14 +13,11 @@ from mvt.common.module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.schemas import MappingOutput
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
|
||||
class CacheFiles(IOSExtraction):
|
||||
output_model = MappingOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from mvt.schemas import MappingOutput
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
@@ -33,8 +32,6 @@ class WebkitSessionResourceLog(IOSExtraction):
|
||||
|
||||
"""
|
||||
|
||||
output_model = MappingOutput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Stable public API for MVT output models and JSON Schemas."""
|
||||
|
||||
from .models import (
|
||||
OUTPUT_SCHEMA_VERSION,
|
||||
AlertResult,
|
||||
AlertResults,
|
||||
FileHash,
|
||||
GenericModuleOutput,
|
||||
MappingOutput,
|
||||
ModuleRecord,
|
||||
OutputModel,
|
||||
RecordListOutput,
|
||||
RunInfo,
|
||||
TimelineEvent,
|
||||
TimelineResults,
|
||||
URLResult,
|
||||
URLResults,
|
||||
)
|
||||
from .registry import (
|
||||
export_json_schemas,
|
||||
get_output_model,
|
||||
get_output_models,
|
||||
schema_bundle,
|
||||
)
|
||||
from .serialization import json_compatible, validate_output, write_output
|
||||
|
||||
__all__ = [
|
||||
"OUTPUT_SCHEMA_VERSION",
|
||||
"AlertResult",
|
||||
"AlertResults",
|
||||
"FileHash",
|
||||
"GenericModuleOutput",
|
||||
"MappingOutput",
|
||||
"ModuleRecord",
|
||||
"OutputModel",
|
||||
"RecordListOutput",
|
||||
"RunInfo",
|
||||
"TimelineEvent",
|
||||
"TimelineResults",
|
||||
"URLResult",
|
||||
"URLResults",
|
||||
"export_json_schemas",
|
||||
"get_output_model",
|
||||
"get_output_models",
|
||||
"json_compatible",
|
||||
"schema_bundle",
|
||||
"validate_output",
|
||||
"write_output",
|
||||
]
|
||||
@@ -1,101 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Public Pydantic models for files written by MVT.
|
||||
|
||||
Module records intentionally remain extensible: each extraction module has its own
|
||||
record fields, while the root models make the top-level output shape predictable.
|
||||
More specific modules can replace :class:`ModuleRecord` with a dedicated model.
|
||||
"""
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, RootModel
|
||||
|
||||
OUTPUT_SCHEMA_VERSION: Literal["1.0"] = "1.0"
|
||||
OutputModel = type[BaseModel]
|
||||
|
||||
|
||||
class MVTOutputModel(BaseModel):
|
||||
"""Base for stable output objects with no undocumented fields."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
|
||||
class ModuleRecord(BaseModel):
|
||||
"""A JSON object produced by an extraction module.
|
||||
|
||||
Module-specific keys are retained and represented in JSON Schema through
|
||||
``additionalProperties``. Dedicated module models can provide stronger field
|
||||
contracts without changing the serialization machinery.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow", strict=True)
|
||||
|
||||
|
||||
class RecordListOutput(RootModel[list[ModuleRecord]]):
|
||||
"""The standard output shape for built-in extraction modules."""
|
||||
|
||||
|
||||
class MappingOutput(RootModel[dict[str, JsonValue]]):
|
||||
"""Output shape for modules grouped by a dynamic string key."""
|
||||
|
||||
|
||||
class GenericModuleOutput(RootModel[list[JsonValue] | dict[str, JsonValue]]):
|
||||
"""Compatibility output shape for third-party modules without a model."""
|
||||
|
||||
|
||||
class FileHash(MVTOutputModel):
|
||||
file_path: str
|
||||
sha256: str
|
||||
|
||||
|
||||
class RunInfo(MVTOutputModel):
|
||||
target_path: Optional[str]
|
||||
mvt_version: str
|
||||
date: str
|
||||
ioc_files: list[str]
|
||||
hashes: list[FileHash]
|
||||
output_schema_version: Literal["1.0"]
|
||||
|
||||
|
||||
class URLResult(MVTOutputModel):
|
||||
url: str
|
||||
expanded_url: Optional[str]
|
||||
timestamp: Optional[str]
|
||||
source: str
|
||||
|
||||
|
||||
class URLResults(RootModel[list[URLResult]]):
|
||||
pass
|
||||
|
||||
|
||||
AlertLevelName = Literal["INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"]
|
||||
|
||||
|
||||
class AlertResult(MVTOutputModel):
|
||||
level: AlertLevelName
|
||||
module: str
|
||||
message: str
|
||||
event_time: str
|
||||
event: dict[str, JsonValue]
|
||||
matched_indicator: Optional[JsonValue] = None
|
||||
|
||||
|
||||
class AlertResults(RootModel[list[AlertResult]]):
|
||||
pass
|
||||
|
||||
|
||||
class TimelineEvent(MVTOutputModel):
|
||||
"""Public contract for the records used to create ``timeline.csv``."""
|
||||
|
||||
timestamp: Optional[str]
|
||||
module: str
|
||||
event: str
|
||||
data: str
|
||||
|
||||
|
||||
class TimelineResults(RootModel[list[TimelineEvent]]):
|
||||
pass
|
||||
@@ -1,133 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Discovery and JSON Schema export for MVT output models."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Iterable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .models import (
|
||||
OUTPUT_SCHEMA_VERSION,
|
||||
AlertResults,
|
||||
RunInfo,
|
||||
TimelineResults,
|
||||
URLResults,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
COMMON_OUTPUT_MODELS: dict[str, type[BaseModel]] = {
|
||||
"alerts": AlertResults,
|
||||
"info": RunInfo,
|
||||
"timeline": TimelineResults,
|
||||
"urls": URLResults,
|
||||
}
|
||||
|
||||
JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
||||
|
||||
|
||||
def _module_classes(platform: str) -> Iterable[type["MVTModule"]]:
|
||||
if platform == "ios":
|
||||
from mvt.ios.modules.backup import BACKUP_MODULES as IOS_BACKUP_MODULES
|
||||
from mvt.ios.modules.fs import FS_MODULES
|
||||
from mvt.ios.modules.mixed import MIXED_MODULES
|
||||
|
||||
return IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
if platform == "android":
|
||||
from mvt.android.modules.androidqf import ANDROIDQF_MODULES
|
||||
from mvt.android.modules.backup import BACKUP_MODULES as ANDROID_BACKUP_MODULES
|
||||
from mvt.android.modules.bugreport import BUGREPORT_MODULES
|
||||
from mvt.android.modules.intrusion_logs import INTRUSION_LOGS_MODULES
|
||||
|
||||
return (
|
||||
ANDROID_BACKUP_MODULES
|
||||
+ BUGREPORT_MODULES
|
||||
+ ANDROIDQF_MODULES
|
||||
+ INTRUSION_LOGS_MODULES
|
||||
)
|
||||
raise ValueError(f"Unsupported MVT platform: {platform}")
|
||||
|
||||
|
||||
def get_output_models(platform: str) -> dict[str, type[BaseModel]]:
|
||||
"""Return common and module output models available for a platform."""
|
||||
|
||||
models = dict(COMMON_OUTPUT_MODELS)
|
||||
for module in _module_classes(platform):
|
||||
models[module.get_slug()] = module.output_model
|
||||
return dict(sorted(models.items()))
|
||||
|
||||
|
||||
def get_output_model(name: str, platform: str | None = None) -> type[BaseModel]:
|
||||
"""Look up an output model by file stem/module slug.
|
||||
|
||||
``platform`` is optional for common outputs. It is required when an output
|
||||
slug exists on both platforms and resolves to different models.
|
||||
"""
|
||||
|
||||
normalized = name.removesuffix(".json")
|
||||
if normalized.endswith("_detected"):
|
||||
return AlertResults
|
||||
if normalized in COMMON_OUTPUT_MODELS:
|
||||
return COMMON_OUTPUT_MODELS[normalized]
|
||||
if platform:
|
||||
try:
|
||||
return get_output_models(platform)[normalized]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"Unknown {platform} output schema: {name}") from exc
|
||||
|
||||
matches = {
|
||||
models[normalized]
|
||||
for candidate in ("ios", "android")
|
||||
if normalized in (models := get_output_models(candidate))
|
||||
}
|
||||
if len(matches) == 1:
|
||||
return matches.pop()
|
||||
if len(matches) > 1:
|
||||
raise KeyError(f"Output schema {name!r} is ambiguous; specify a platform")
|
||||
raise KeyError(f"Unknown MVT output schema: {name}")
|
||||
|
||||
|
||||
def schema_bundle(platform: str) -> dict[str, Any]:
|
||||
"""Build a versioned bundle of Draft 2020-12 JSON Schemas."""
|
||||
|
||||
return {
|
||||
"output_schema_version": OUTPUT_SCHEMA_VERSION,
|
||||
"platform": platform,
|
||||
"schemas": {
|
||||
name: _output_json_schema(name, model, platform)
|
||||
for name, model in get_output_models(platform).items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _output_json_schema(
|
||||
name: str, model: type[BaseModel], platform: str
|
||||
) -> dict[str, Any]:
|
||||
schema = model.model_json_schema()
|
||||
return {
|
||||
"$schema": JSON_SCHEMA_DIALECT,
|
||||
"$id": f"urn:mvt:output-schema:{OUTPUT_SCHEMA_VERSION}:{platform}:{name}",
|
||||
**schema,
|
||||
}
|
||||
|
||||
|
||||
def export_json_schemas(destination: str | os.PathLike[str], platform: str) -> list[Path]:
|
||||
"""Write one JSON Schema per output and return the paths created."""
|
||||
|
||||
destination_path = Path(destination)
|
||||
destination_path.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
for name, model in get_output_models(platform).items():
|
||||
output_path = destination_path / f"{name}.schema.json"
|
||||
with output_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(_output_json_schema(name, model, platform), handle, indent=2)
|
||||
handle.write("\n")
|
||||
written.append(output_path)
|
||||
return written
|
||||
@@ -1,35 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Validation and serialization helpers shared by all MVT output writers."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mvt.common.utils import CustomJSONEncoder
|
||||
|
||||
|
||||
def json_compatible(value: Any) -> Any:
|
||||
"""Apply MVT's legacy conversions before validating JSON-native values."""
|
||||
|
||||
return json.loads(json.dumps(value, cls=CustomJSONEncoder))
|
||||
|
||||
|
||||
def validate_output(model: type[BaseModel], value: Any) -> Any:
|
||||
"""Validate an output document and return JSON-serializable Python values."""
|
||||
|
||||
compatible = json_compatible(value)
|
||||
return model.model_validate(compatible).model_dump(mode="json")
|
||||
|
||||
|
||||
def write_output(path: str | Path, model: type[BaseModel], value: Any) -> None:
|
||||
"""Validate and write an MVT JSON document."""
|
||||
|
||||
validated = validate_output(model, value)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(validated, handle, indent=4)
|
||||
@@ -46,6 +46,23 @@ class TestDumpsysAccessibilityArtifact:
|
||||
# "not stated" into "not installed", so it reads None here.
|
||||
assert da.results[0]["installed"] is None
|
||||
|
||||
def test_parsing_installed_and_enabled(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
with open(get_artifact("android_data/dumpsys_accessibility_enabled.txt")) as f:
|
||||
da.parse(f.read())
|
||||
|
||||
assert len(da.results) == 5
|
||||
assert all(result["component"] for result in da.results)
|
||||
assert all(result["installed"] is True for result in da.results)
|
||||
enabled = [result for result in da.results if result["enabled"] is True]
|
||||
assert len(enabled) == 1
|
||||
assert enabled[0]["package_name"] == "com.samsung.accessibility"
|
||||
assert (
|
||||
enabled[0]["component"]
|
||||
== "com.samsung.accessibility/.universalswitch.UniversalSwitchService"
|
||||
)
|
||||
assert sum(result["enabled"] is False for result in da.results) == 4
|
||||
|
||||
def test_accessibility_service_alert(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
file = get_artifact("android_data/dumpsys_accessibility_v14_or_later.txt")
|
||||
|
||||
@@ -209,85 +209,6 @@ 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(
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
# 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
|
||||
@@ -23,21 +21,6 @@ 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()
|
||||
@@ -176,32 +159,6 @@ 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()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
|
||||
from mvt.android.modules.bugreport.dumpsys_accessibility import DumpsysAccessibility
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
|
||||
def _run(content, monkeypatch, caplog):
|
||||
module = DumpsysAccessibility()
|
||||
dumpstate = f"DUMP OF SERVICE accessibility:\n{content}".encode()
|
||||
monkeypatch.setattr(module, "_get_dumpstate_file", lambda: dumpstate)
|
||||
with caplog.at_level(logging.INFO):
|
||||
module.run()
|
||||
|
||||
|
||||
def test_logs_mixed_enabled_states(monkeypatch, caplog):
|
||||
with open(get_artifact("android_data/dumpsys_accessibility_enabled.txt")) as handle:
|
||||
_run(handle.read(), monkeypatch, caplog)
|
||||
|
||||
assert (
|
||||
'Found accessibility service "com.samsung.accessibility/'
|
||||
'.universalswitch.UniversalSwitchService" (enabled: True)' in caplog.messages
|
||||
)
|
||||
assert sum("(enabled: False)" in message for message in caplog.messages) == 4
|
||||
assert (
|
||||
"Identified a total of 5 accessibility services, 1 reported enabled "
|
||||
"(0 more stated by the dump without a component name)" in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
def test_logs_enabled_count_alongside_unnamed_count(monkeypatch, caplog):
|
||||
with open(
|
||||
get_artifact("android_data/dumpsys_accessibility_v14_or_later.txt")
|
||||
) as handle:
|
||||
_run(handle.read(), monkeypatch, caplog)
|
||||
|
||||
assert (
|
||||
"Identified a total of 1 accessibility services, 1 reported enabled "
|
||||
"(1 more stated by the dump without a component name)" in caplog.messages
|
||||
)
|
||||
assert (
|
||||
sum("Found accessibility service" in message for message in caplog.messages)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_logs_unknown_enabled_state_without_treating_it_as_disabled(
|
||||
monkeypatch, caplog
|
||||
):
|
||||
_run(
|
||||
"""User state[attributes:{id=0, installedServiceCount=2}
|
||||
installed services: {
|
||||
0 : com.example/.Service
|
||||
}
|
||||
""",
|
||||
monkeypatch,
|
||||
caplog,
|
||||
)
|
||||
|
||||
assert (
|
||||
'Found accessibility service "com.example/.Service" (enabled: not stated)'
|
||||
in caplog.messages
|
||||
)
|
||||
assert not any("(enabled: False)" in message for message in caplog.messages)
|
||||
assert (
|
||||
"Identified a total of 1 accessibility services, 0 reported enabled "
|
||||
"(1 more stated by the dump without a component name)" in caplog.messages
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
|
||||
currentUserId=0
|
||||
User state[
|
||||
attributes:{id=0, touchExplorationEnabled=false, installedServiceCount=5}
|
||||
installed services: {
|
||||
0 : com.google.android.apps.accessibility.voiceaccess/.JustSpeakService (A11yTool)
|
||||
1 : com.microsoft.appmanager/com.microsoft.mmx.screenmirroringsrc.accessibility.ScreenMirroringAccessibilityService
|
||||
2 : com.samsung.accessibility/.assistantmenu.serviceframework.AssistantMenuService (A11yTool)
|
||||
3 : com.samsung.accessibility/.universalswitch.UniversalSwitchService (A11yTool)
|
||||
4 : com.samsung.android.accessibility.talkback/com.samsung.android.marvin.talkback.TalkBackService (A11yTool)
|
||||
}
|
||||
Bound services:{}
|
||||
Enabled services:{{com.samsung.accessibility/.universalswitch.UniversalSwitchService}}
|
||||
Binding services:{}
|
||||
Crashed services:{}
|
||||
@@ -1,131 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError
|
||||
|
||||
from mvt.android.cli import cli as android_cli
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
from mvt.schemas import (
|
||||
OUTPUT_SCHEMA_VERSION,
|
||||
GenericModuleOutput,
|
||||
MappingOutput,
|
||||
RecordListOutput,
|
||||
export_json_schemas,
|
||||
get_output_model,
|
||||
get_output_models,
|
||||
schema_bundle,
|
||||
validate_output,
|
||||
)
|
||||
|
||||
|
||||
class CountRecord(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
count: int
|
||||
|
||||
|
||||
class CountOutput(RootModel[list[CountRecord]]):
|
||||
pass
|
||||
|
||||
|
||||
class CountModule(MVTModule):
|
||||
output_model = CountOutput
|
||||
|
||||
|
||||
def test_common_output_model_validates_without_changing_root_shape():
|
||||
assert validate_output(RecordListOutput, [{"name": "example", "size": 2}]) == [
|
||||
{"name": "example", "size": 2}
|
||||
]
|
||||
assert validate_output(MappingOutput, {"global": {"enabled": "1"}}) == {
|
||||
"global": {"enabled": "1"}
|
||||
}
|
||||
|
||||
|
||||
def test_module_model_is_used_when_loading_existing_output(tmp_path):
|
||||
path = tmp_path / "count.json"
|
||||
path.write_text('[{"count": 4}]', encoding="utf-8")
|
||||
|
||||
module = CountModule.from_json(str(path), logging.getLogger(__name__))
|
||||
|
||||
assert module.results == [{"count": 4}]
|
||||
|
||||
|
||||
def test_invalid_module_output_is_not_written(tmp_path, caplog):
|
||||
module = CountModule(results_path=str(tmp_path), results=[{"count": "four"}])
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
module.save_to_json()
|
||||
|
||||
assert not (tmp_path / "count_module.json").exists()
|
||||
assert "does not match schema CountOutput" in caplog.text
|
||||
|
||||
|
||||
def test_invalid_existing_output_raises_validation_error(tmp_path):
|
||||
path = tmp_path / "count.json"
|
||||
path.write_text('[{"count": "four"}]', encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CountModule.from_json(str(path), logging.getLogger(__name__))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["ios", "android"])
|
||||
def test_every_builtin_module_has_a_non_generic_output_model(platform):
|
||||
module_models = {
|
||||
name: model
|
||||
for name, model in get_output_models(platform).items()
|
||||
if name not in {"alerts", "info", "timeline", "urls"}
|
||||
}
|
||||
|
||||
assert module_models
|
||||
assert GenericModuleOutput not in module_models.values()
|
||||
|
||||
|
||||
def test_known_mapping_and_specific_models_are_registered():
|
||||
assert get_output_model("backup_info.json", "ios") is MappingOutput
|
||||
assert get_output_model("dumpsys_receivers", "android") is MappingOutput
|
||||
assert get_output_model("tombstones", "android").__name__ == "TombstoneCrashOutput"
|
||||
assert get_output_model("sms_detected.json", "ios").__name__ == "AlertResults"
|
||||
|
||||
|
||||
def test_schema_bundle_has_stable_versioned_shape():
|
||||
bundle = schema_bundle("android")
|
||||
|
||||
assert bundle["output_schema_version"] == OUTPUT_SCHEMA_VERSION
|
||||
assert bundle["platform"] == "android"
|
||||
assert bundle["schemas"]["urls"]["$schema"].endswith("2020-12/schema")
|
||||
assert bundle["schemas"]["urls"]["type"] == "array"
|
||||
assert bundle["schemas"]["info"]["additionalProperties"] is False
|
||||
assert bundle["schemas"]["dumpsys_receivers"]["type"] == "object"
|
||||
|
||||
|
||||
def test_export_json_schemas_writes_one_file_per_registered_output(tmp_path):
|
||||
paths = export_json_schemas(tmp_path, "android")
|
||||
|
||||
assert len(paths) == len(get_output_models("android"))
|
||||
assert json.loads((tmp_path / "info.schema.json").read_text())["title"] == (
|
||||
"RunInfo"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cli", "platform"), [(ios_cli, "ios"), (android_cli, "android")]
|
||||
)
|
||||
def test_schema_cli_prints_machine_readable_bundle(cli, platform):
|
||||
result = CliRunner().invoke(cli, ["schemas"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output)["platform"] == platform
|
||||
|
||||
|
||||
def test_schema_cli_exports_schema_files(tmp_path):
|
||||
result = CliRunner().invoke(android_cli, ["schemas", "--output", str(tmp_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (tmp_path / "urls.schema.json").exists()
|
||||
@@ -5,12 +5,7 @@
|
||||
|
||||
import gc
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import run_module
|
||||
@@ -19,58 +14,8 @@ from mvt.ios.modules.backup.manifest import Manifest
|
||||
|
||||
from ..utils import get_ios_backup_folder
|
||||
|
||||
# fileID of HomeDomain::Library/SMS/sms.db in the test backup. It is one of the
|
||||
# few files the test backup actually stores.
|
||||
SMS_FILE_ID = "3d0d7e5fb2ce288813306e4d4636395e047a3d28"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backup_without_stored_files(tmp_path):
|
||||
"""A copy of the test backup with every stored file removed.
|
||||
|
||||
The test backup only ships a handful of the files its manifest lists, so
|
||||
dropping what it does store leaves a backup where every regular file the
|
||||
manifest mentions is missing from the folder.
|
||||
"""
|
||||
backup_path = tmp_path / "backup"
|
||||
shutil.copytree(get_ios_backup_folder(), backup_path)
|
||||
|
||||
for file_id_folder in backup_path.iterdir():
|
||||
if not file_id_folder.is_dir():
|
||||
continue
|
||||
for backup_file in file_id_folder.iterdir():
|
||||
backup_file.unlink()
|
||||
|
||||
return str(backup_path)
|
||||
|
||||
|
||||
class TestIOSExtraction:
|
||||
@pytest.mark.parametrize("link_directory", [False, True], ids=["file", "directory"])
|
||||
@pytest.mark.parametrize("inside_backup", [False, True], ids=["outside", "inside"])
|
||||
def test_stored_file_inventory_matches_symlink_resolution(
|
||||
self, tmp_path, link_directory, inside_backup
|
||||
):
|
||||
backup_path = tmp_path / "backup"
|
||||
backup_path.mkdir()
|
||||
destination = (backup_path if inside_backup else tmp_path) / "contents"
|
||||
destination.mkdir()
|
||||
(destination / SMS_FILE_ID).write_bytes(b"backup file")
|
||||
bucket = backup_path / SMS_FILE_ID[:2]
|
||||
try:
|
||||
if link_directory:
|
||||
bucket.symlink_to(destination, target_is_directory=True)
|
||||
else:
|
||||
bucket.mkdir()
|
||||
(bucket / SMS_FILE_ID).symlink_to(destination / SMS_FILE_ID)
|
||||
except OSError:
|
||||
pytest.skip("creating symbolic links is not permitted on this system")
|
||||
|
||||
m = IOSExtraction(target_path=str(backup_path))
|
||||
assert bool(m._get_backup_file_from_id(SMS_FILE_ID)) is inside_backup
|
||||
assert m._get_stored_backup_file_ids() == (
|
||||
{SMS_FILE_ID} if inside_backup else set()
|
||||
)
|
||||
|
||||
def test_get_backup_files_from_manifest_closes_connection(self):
|
||||
m = IOSExtraction(target_path=get_ios_backup_folder())
|
||||
|
||||
@@ -96,103 +41,6 @@ class TestManifestModule:
|
||||
assert len(m.timeline) == 5881
|
||||
assert len(m.alertstore.alerts) == 0
|
||||
|
||||
def test_manifest_flags_the_files_missing_from_an_incomplete_backup(self):
|
||||
m = Manifest(target_path=get_ios_backup_folder())
|
||||
run_module(m)
|
||||
|
||||
missing = [result for result in m.results if result.get("missing")]
|
||||
# The test backup only stores a handful of the files its manifest
|
||||
# lists, and every one of the rest is a regular file (flags 1).
|
||||
assert len(missing) == 1079
|
||||
assert all(result["flags"] == 1 for result in missing)
|
||||
|
||||
stored = [result for result in m.results if result["file_id"] == SMS_FILE_ID]
|
||||
assert len(stored) == 1
|
||||
assert "missing" not in stored[0]
|
||||
|
||||
def test_manifest_flags_every_stored_file_removed_from_the_backup_folder(
|
||||
self, backup_without_stored_files
|
||||
):
|
||||
m = Manifest(target_path=backup_without_stored_files)
|
||||
run_module(m)
|
||||
|
||||
missing = [result for result in m.results if result.get("missing")]
|
||||
assert len(missing) == 1089
|
||||
|
||||
def test_manifest_flags_a_file_removed_from_the_backup_folder(self, tmp_path):
|
||||
backup_path = tmp_path / "backup"
|
||||
shutil.copytree(get_ios_backup_folder(), backup_path)
|
||||
(backup_path / SMS_FILE_ID[:2] / SMS_FILE_ID).unlink()
|
||||
|
||||
m = Manifest(target_path=str(backup_path))
|
||||
run_module(m)
|
||||
|
||||
removed = [result for result in m.results if result["file_id"] == SMS_FILE_ID]
|
||||
assert len(removed) == 1
|
||||
assert removed[0]["missing"] is True
|
||||
|
||||
@pytest.mark.parametrize("failed_folder", ["", "3d"], ids=["root", "bucket"])
|
||||
def test_manifest_skips_missing_check_when_inventory_fails(
|
||||
self, monkeypatch, caplog, failed_folder
|
||||
):
|
||||
backup_path = Path(get_ios_backup_folder())
|
||||
failed_path = backup_path / failed_folder
|
||||
scandir = os.scandir
|
||||
|
||||
def fail_listing(path):
|
||||
if Path(path) == failed_path:
|
||||
raise PermissionError("cannot list backup folder")
|
||||
return scandir(path)
|
||||
|
||||
monkeypatch.setattr(os, "scandir", fail_listing)
|
||||
m = Manifest(target_path=str(backup_path))
|
||||
with caplog.at_level(logging.INFO):
|
||||
m.run()
|
||||
|
||||
assert len(m.results) == 3721
|
||||
assert m._get_backup_file_from_id(SMS_FILE_ID) is not None
|
||||
assert all("missing" not in result for result in m.results)
|
||||
assert "Skipping the missing-file check" in caplog.text
|
||||
assert "The backup might be incomplete" not in caplog.text
|
||||
|
||||
def test_manifest_ignores_unreadable_unrelated_folders(self, tmp_path, monkeypatch):
|
||||
backup_path = tmp_path / "backup"
|
||||
shutil.copytree(get_ios_backup_folder(), backup_path)
|
||||
unrelated = backup_path / "notes"
|
||||
unrelated.mkdir()
|
||||
scandir = os.scandir
|
||||
|
||||
def fail_listing(path):
|
||||
if Path(path) == unrelated:
|
||||
raise PermissionError("cannot list unrelated folder")
|
||||
return scandir(path)
|
||||
|
||||
monkeypatch.setattr(os, "scandir", fail_listing)
|
||||
m = Manifest(target_path=str(backup_path))
|
||||
m.run()
|
||||
|
||||
assert sum(bool(result.get("missing")) for result in m.results) == 1079
|
||||
stored = next(
|
||||
result for result in m.results if result["file_id"] == SMS_FILE_ID
|
||||
)
|
||||
assert "missing" not in stored
|
||||
|
||||
@pytest.mark.parametrize("wrong_folder", ["ab", "notes"])
|
||||
def test_manifest_flags_files_in_the_wrong_folder(self, tmp_path, wrong_folder):
|
||||
backup_path = tmp_path / "backup"
|
||||
shutil.copytree(get_ios_backup_folder(), backup_path)
|
||||
destination = backup_path / wrong_folder
|
||||
destination.mkdir(exist_ok=True)
|
||||
(backup_path / SMS_FILE_ID[:2] / SMS_FILE_ID).rename(destination / SMS_FILE_ID)
|
||||
|
||||
m = Manifest(target_path=str(backup_path))
|
||||
m.run()
|
||||
|
||||
assert m._get_backup_file_from_id(SMS_FILE_ID) is None
|
||||
moved = next(result for result in m.results if result["file_id"] == SMS_FILE_ID)
|
||||
assert moved["missing"] is True
|
||||
assert sum(bool(result.get("missing")) for result in m.results) == 1080
|
||||
|
||||
def test_detection(self, indicator_file):
|
||||
m = Manifest(target_path=get_ios_backup_folder())
|
||||
ind = Indicators(log=logging.getLogger())
|
||||
|
||||
Reference in new issue
Block a user