Compare commits

..
Author SHA1 Message Date
Donncha Ó Cearbhaill 4e031cd43f Move development documentation under docs/development
The Development section holds more than one page and will grow further,
so give it its own directory. development.md becomes development/index.md,
which keeps its published URL, and the custom CLI command page moves
alongside it.

Update the nav, the cross-link between the two pages, and the README
link to the custom command documentation, which is now published at
/development/custom_commands/.
2026-08-26 11:48:03 +02:00
167 changed files with 2011 additions and 11614 deletions

No files matched your search

@@ -37,8 +37,6 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
fetch-depth: 0 # the package version comes from the git tags
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
-45
View File
@@ -1,45 +0,0 @@
name: Release
on:
schedule:
- cron: "0 9 * * 1" # Mondays, 09:00 UTC
workflow_dispatch:
push:
tags: ["v*"]
jobs:
tag:
if: github.ref_type != 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- run: |
last=$(git describe --tags --abbrev=0 --match 'v*')
if [ "$(git rev-list "$last..HEAD" --count)" = 0 ]; then
echo "Nothing merged since $last"; exit 0
fi
tag=v$(date -u +%Y.%-m.%-d)
gh release create "$tag" --target "$GITHUB_SHA" --generate-notes
# Events made with GITHUB_TOKEN don't start other workflows; run them by hand.
gh workflow run release.yml --ref "$tag"
gh workflow run publish-release-docker.yml --ref "$tag"
publish:
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
environment: pypi # only tag refs may deploy; the PyPI trusted publisher requires it
permissions:
id-token: write # PyPI trusted publishing
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.0.0
- run: uv build && uv publish
+4 -19
View File
@@ -5,10 +5,6 @@ on:
pull_request: pull_request:
branches: [ main ] branches: [ main ]
permissions:
contents: read
pull-requests: write # coverage comment
jobs: jobs:
build: build:
name: Run Python Tests name: Run Python Tests
@@ -17,10 +13,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
env:
# Takes precedence over .python-version, which otherwise makes `uv run`
# rebuild the venv with 3.10 and test every matrix entry on 3.10.
UV_PYTHON: ${{ matrix.python-version }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@@ -34,23 +26,16 @@ jobs:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
uv sync --locked --group dev uv sync --locked --group dev --python ${{ matrix.python-version }}
- name: Test with pytest - name: Test with pytest
run: | run: |
set -o pipefail set -o pipefail
make test-ci | tee pytest-coverage.txt make test-ci | tee pytest-coverage.txt
- name: Coverage job summary
run: uv run coverage report --format=markdown --show-missing --skip-covered >> "$GITHUB_STEP_SUMMARY"
- name: Pytest coverage comment - name: Pytest coverage comment
# One comment per PR, not one per matrix entry. PRs from forks get a continue-on-error: true # Workflows running on a fork can't post comments
# read-only token and can't post; the job summary above still works. uses: MishaKav/pytest-coverage-comment@main
if: github.event_name == 'pull_request' && matrix.python-version == '3.13' if: github.event_name == 'pull_request'
continue-on-error: true
uses: MishaKav/pytest-coverage-comment@v1.12.2
with: with:
pytest-coverage-path: ./pytest-coverage.txt pytest-coverage-path: ./pytest-coverage.txt
junitxml-path: ./pytest.xml junitxml-path: ./pytest.xml
# The full table with per-line links exceeds GitHub's 65536-char comment limit.
report-only-changed-files: true
remove-links-to-lines: true
+116 -14
View File
@@ -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 # Create main image
FROM ubuntu:24.04 AS main FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/ 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.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.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.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 # Install runtime dependencies
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -18,9 +122,9 @@ RUN apt-get update \
binutils \ binutils \
default-jre-headless \ default-jre-headless \
file \ file \
git \
jq \ jq \
less \ less \
libcurl4 \
libimage-exiftool-perl \ libimage-exiftool-perl \
libssl3 \ libssl3 \
libusb-1.0-0 \ libusb-1.0-0 \
@@ -31,20 +135,18 @@ RUN apt-get update \
sqlite3 \ sqlite3 \
tree \ tree \
unzip \ unzip \
xxd \ xxd
&& rm -rf /var/lib/apt/lists/* 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. # Install mvt using the locally checked out source
ARG PYMOBILEDEVICE3_VERSION=11.12.4
COPY . mvt/ COPY . mvt/
RUN apt-get update \ RUN uv pip install --system --break-system-packages --no-cache ./mvt \
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \ && rm -rf mvt
&& 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 # Installing ABE
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
+122 -22
View File
@@ -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 # 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/ COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re" 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.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.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.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 # Install runtime dependencies
ARG DEBIAN_FRONTEND=noninteractive RUN apk add --no-cache \
RUN apt-get update \ gcompat \
&& apt-get install -y --no-install-recommends \ libcurl \
ca-certificates \
git \
libssl3 \ libssl3 \
libusb-1.0-0 \ libusb \
python3 \ sqlite
sqlite3 \ COPY --from=build-libplist /build /
&& rm -rf /var/lib/apt/lists/* 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. # Install mvt using the locally checked out source
ARG PYMOBILEDEVICE3_VERSION=11.12.4 COPY ./ mvt
COPY . mvt/ RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \
RUN apt-get update \ && uv pip install --system --no-cache ./mvt \
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \ && apk del .build-deps git gcc musl-dev && rm -rf ./mvt
&& 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" ] ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
-4
View File
@@ -1,4 +0,0 @@
prune tests
prune docs
prune .github
exclude uv.lock Dockerfile* mkdocs.yml .readthedocs.yaml .safety-policy.yml .python-version .gitignore Makefile
+6
View File
@@ -31,3 +31,9 @@ clean:
dist: dist:
$(UV) build $(UV) build
upload:
$(UV) tool run twine upload dist/*
test-upload:
$(UV) tool run twine upload --repository testpypi dist/*
+14 -18
View File
@@ -58,38 +58,34 @@ For alternative installation options and known issues, please refer to the [docu
## Usage ## Usage
MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion`, `plugins` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/) MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
Pass `--verbose` to any of the three commands, before the command name (`mvt-ios --verbose check-backup ...`), for debug output. The `--verbose` option the `check-*` commands accept after their name still works but is kept for compatibility only and will be removed in a future release.
### Shell completion ### Shell completion
MVT can generate a shell completion script for Bash, Zsh, and Fish which covers `mvt`, `mvt-ios` and `mvt-android`: MVT can generate shell completion scripts for Bash, Zsh, and Fish:
```bash ```bash
mvt completion mvt-ios completion
mvt-android completion
``` ```
The command prints setup instructions by default. To generate the completion script directly, pass the shell name: The commands print setup instructions by default. To generate a completion script directly, pass the shell name:
```bash ```bash
mvt completion bash mvt-ios completion bash
mvt-android completion zsh
``` ```
MVT only writes completion files or shell configuration when `--install` is passed. See the [command completion documentation](https://docs.mvt.re/en/latest/command_completion/) for details. MVT only writes completion files or shell configuration when `--install` is passed. See the [command completion documentation](https://docs.mvt.re/en/latest/command_completion/) for details.
Module-running `check-*` commands can load custom Python modules with
Plugin packages extend MVT with additional forensic modules, which run inside `--load-module PATH` or from a folder set in `MVT_CUSTOM_MODULES`. See the
the `check-*` commands, and with top-level commands on `mvt`, `mvt-ios` and
`mvt-android`. See the
[development documentation](https://docs.mvt.re/en/latest/development/) for [development documentation](https://docs.mvt.re/en/latest/development/) for
writing and installing them, and the details.
[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
for the entry points a package registers commands in.
Extraction commands run independent modules concurrently with four workers by Users can also add top-level commands to `mvt-ios` and `mvt-android` from
default. Use `--jobs 1` for sequential execution or another positive value to installed Python packages or local files and folders. See the
set the worker limit. Dependencies retain their declared ordering, and parallel [custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
console logs are printed as grouped module blocks. for the plugin entry points and `--load-command` interface.
## License ## License
+1 -1
View File
@@ -58,7 +58,7 @@ mvt-android check-intrusion-logs --output /path/to/results/ /path/to/intrusion-l
| `-l, --list-modules` | List the available intrusion-log modules and exit. | | `-l, --list-modules` | List the available intrusion-log modules and exit. |
| `-m, --module NAME` | Run a single module (e.g. `DnsEvent`) instead of all of them. | | `-m, --module NAME` | Run a single module (e.g. `DnsEvent`) instead of all of them. |
| `-t, --timezone TZ` | IANA timezone name for the device (e.g. `Europe/Paris`). When set, event timestamps are converted to the device's local time instead of UTC. | | `-t, --timezone TZ` | IANA timezone name for the device (e.g. `Europe/Paris`). When set, event timestamps are converted to the device's local time instead of UTC. |
| `-v, --verbose` | Verbose logging. Kept for compatibility and to be removed in a future release: pass `--verbose` to `mvt-android` itself instead. | | `-v, --verbose` | Verbose logging. |
## Modules ## Modules
+22 -22
View File
@@ -6,61 +6,61 @@ Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fi
To enable it, you need to register a completion script with your shell, which varies depending on the shell you are using. To enable it, you need to register a completion script with your shell, which varies depending on the shell you are using.
`mvt completion` generates one script which covers `mvt`, `mvt-ios` and `mvt-android`. The following describes how to generate that script and add it to your shell configuration. The following describes how to generate the command completion scripts and add them to your shell configuration.
> **Note: You will need to start a new shell for the changes to take effect.** > **Note: You will need to start a new shell for the changes to take effect.**
### For Bash ### For Bash
```bash ```bash
# Generate the bash completion script # Generate bash completion scripts
mvt completion bash > ~/.mvt-complete.bash mvt-ios completion bash > ~/.mvt-ios-complete.bash
mvt-android completion bash > ~/.mvt-android-complete.bash
``` ```
Add the following to `~/.bashrc`: Add the following to `~/.bashrc`:
```bash ```bash
# source the mvt completion script # source mvt completion scripts
[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash [ -f ~/.mvt-ios-complete.bash ] && . ~/.mvt-ios-complete.bash
[ -f ~/.mvt-android-complete.bash ] && . ~/.mvt-android-complete.bash
``` ```
### For Zsh ### For Zsh
```bash ```bash
# Generate the zsh completion script # Generate zsh completion scripts
mvt completion zsh > ~/.mvt-complete.zsh mvt-ios completion zsh > ~/.mvt-ios-complete.zsh
mvt-android completion zsh > ~/.mvt-android-complete.zsh
``` ```
Add the following to `~/.zshrc`: Add the following to `~/.zshrc`:
```bash ```bash
# source the mvt completion script # source mvt completion scripts
[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-complete.zsh [ -f ~/.mvt-ios-complete.zsh ] && . ~/.mvt-ios-complete.zsh
[ -f ~/.mvt-android-complete.zsh ] && . ~/.mvt-android-complete.zsh
``` ```
### For Fish ### For Fish
```bash ```bash
# Generate the fish completion script # Generate fish completion scripts
mkdir -p ~/.config/fish/conf.d mkdir -p ~/.config/fish/completions
mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish mvt-ios completion fish > ~/.config/fish/completions/mvt-ios.fish
mvt-android completion fish > ~/.config/fish/completions/mvt-android.fish
``` ```
Fish loads the files in `~/.config/fish/conf.d` automatically. Fish loads completion files from `~/.config/fish/completions` automatically.
### Automatic Installation ### Automatic Installation
MVT can write the completion file and update the relevant shell configuration for Bash and Zsh when you pass `--install`: MVT can write the completion file and update the relevant shell configuration for Bash and Zsh when you pass `--install`:
```bash ```bash
mvt completion bash --install mvt-ios completion bash --install
mvt-android completion bash --install
``` ```
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/conf.d` and changes no shell configuration. Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/completions`.
!!! note
Earlier versions generated one script per command, with `mvt-ios completion`
and `mvt-android completion`. Files written by them keep working. When you
switch to the single script, remove the old files and the lines which load
them from your shell configuration.
For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion). For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion).
+15 -110
View File
@@ -1,8 +1,6 @@
# Custom CLI Commands # Custom CLI Commands
MVT can load additional top-level commands into `mvt`, `mvt-ios` and MVT can load additional top-level commands into `mvt-ios` and `mvt-android`.
`mvt-android`. A command package chooses which of the three each of its
commands is added to.
Custom commands are different from [custom forensic modules](index.md#custom-modules): Custom commands are different from [custom forensic modules](index.md#custom-modules):
commands add new CLI operations, while modules add analysis steps to existing commands add new CLI operations, while modules add analysis steps to existing
`check-*` commands. `check-*` commands.
@@ -15,8 +13,8 @@ commands add new CLI operations, while modules add analysis steps to existing
## Install a Command Package ## Install a Command Package
Python packages can register a Click command or group on one or more of the MVT CLIs. Python packages can register a Click command or group for either MVT CLI. A
A minimal package can expose this command from `my_mvt_plugin.py`: minimal package can expose this command from `my_mvt_plugin.py`:
```python ```python
import click import click
@@ -29,10 +27,6 @@ def summarize(path):
click.echo(f"Summarizing {path}") click.echo(f"Summarizing {path}")
``` ```
Log through `get_plugin_logger(__name__)` from `mvt.plugin`. Records logged
through `logging.getLogger(__name__)` do not reach `command.log`, and MVT's
console handler does not show them.
Register the object in the package's `pyproject.toml`. The entry-point name is Register the object in the package's `pyproject.toml`. The entry-point name is
the command users invoke: the command users invoke:
@@ -44,13 +38,9 @@ summarize = "my_mvt_plugin:summarize"
summarize = "my_mvt_plugin:summarize" summarize = "my_mvt_plugin:summarize"
``` ```
Each entry-point group adds the command to one CLI: `mvt.ios.cli_plugins` to Use only the iOS or Android group if the command is platform-specific. After
`mvt-ios`, `mvt.android.cli_plugins` to `mvt-android` and `mvt.cli_plugins` to installing the package in the same environment as MVT, it appears directly in
`mvt`. Register the command in the group of every CLI which should offer it: a the appropriate CLI:
platform-specific command belongs in one platform group, and a command which
handles acquisitions of both platforms, as above, in both. After installing the
package in the same environment as MVT, the command appears directly in those
CLIs:
```bash ```bash
mvt-ios summarize ./ios-backup mvt-ios summarize ./ios-backup
@@ -64,35 +54,12 @@ pipx inject mvt my-mvt-plugin
``` ```
When MVT is installed in an active virtual environment, install the plugin with When MVT is installed in an active virtual environment, install the plugin with
`pip` in that environment. `mvt plugins list` shows the installed packages and `pip` in that environment.
the commands they add, see [Managing Plugins](plugins.md).
Command packages that need their own settings, such as an API key, should store ## Load a Command File
them in a namespaced [plugin configuration file](plugin_configuration.md)
rather than in MVT's own `config.yaml`.
### Commands on `mvt` For local commands that are not packaged, create a Python file that exports one
Click command or group named `cli`:
A MVT plugin command can also add sub-commands to the base `mvt` command. This can be used for commands which are not tied to a particular forensic platform:
```toml
[project.entry-points."mvt.cli_plugins"]
my-plugin = "my_mvt_plugin:my_plugin"
```
Commands in this group are added to `mvt` only, so this one is invoked as
`mvt my-plugin`. A command on `mvt` has nothing but its name to say which
plugin it belongs to, so name it after the plugin, and make it a Click group
when the plugin has several operations to offer, such as
`mvt my-plugin configure`.
## Developing a Command Locally
A package is how a command is distributed. While a command is being written,
MVT can load it straight from its file instead, so the package need not be
reinstalled after every change; an editable install of the package does the
same through its entry points. Create a Python file that exports one Click
command or group named `cli`:
```python ```python
import click import click
@@ -115,82 +82,20 @@ mvt-ios --load-command ./case_summary.py case-summary ./ios-backup
non-hidden top-level `*.py` files in sorted order and skips `__init__.py`. non-hidden top-level `*.py` files in sorted order and skips `__init__.py`.
Every loaded file must export one `cli` object. Every loaded file must export one `cli` object.
To load a file or folder on every invocation, set the environment variable of To load a file or folder on every invocation, set the platform-specific
the CLI the commands belong on. Like the entry-point groups, each variable adds environment variable:
its commands to one CLI only:
```bash ```bash
export MVT_CUSTOM_COMMANDS=./commands
export MVT_IOS_CUSTOM_COMMANDS=./ios_commands export MVT_IOS_CUSTOM_COMMANDS=./ios_commands
export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands
``` ```
## Building a Module-Running Command
A command which runs forensic modules over an acquisition subclasses `Command`.
`Command` creates the output folder and writes `command.log`. It orders the
modules, resolves their dependencies and runs them. It writes the result files,
`alerts.json` and `info.json`. The subclass sets `platform`, `name` and
`modules`:
```python
from mvt.plugin import Command, MVTModule, convert_unix_to_iso, get_plugin_logger
log = get_plugin_logger(__name__)
class APKManifest(MVTModule):
supported_commands = (("android", "check-apks"),)
def run(self):
self.results = [{"checked_at": convert_unix_to_iso(0)}]
class CmdCheckAPKs(Command):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.platform = "android"
self.name = "check-apks"
self.modules = [APKManifest]
```
`platform` and `name` are the pair the modules declare in
`supported_commands`. `modules` lists the module classes the command runs,
imported directly. A command which runs modules of another plugin depends on
that package in `pyproject.toml` and imports them the same way. `init()`,
`module_init(module)` and `finish()` are optional hooks. `run()` calls them
before the run, before each module and after the run.
Wrap the command in a Click command with an `--output` option. Run it, then
print the alert summary:
```python
import click
@click.command("check-apks")
@click.option("--output", "-o", type=click.Path(exists=False))
@click.argument("TARGET_PATH", type=click.Path(exists=True))
def cli(output, target_path):
cmd = CmdCheckAPKs(target_path=target_path, results_path=output)
log.info("Checking APK files at path: %s", target_path)
cmd.run()
cmd.show_alerts_brief()
```
The `--verbose` option of `mvt`, `mvt-ios` and `mvt-android` applies to the
command. The command defines no `--verbose` option of its own. The pair a
plugin command adds is not listed anywhere in MVT. Name it in the plugin's
README.
## Naming and Errors ## Naming and Errors
Built-in MVT commands cannot be replaced. External command names must also be Built-in MVT commands cannot be replaced. External command names must also be
unique on each CLI; when installed packages or environment paths collide, MVT unique; when installed packages or environment paths collide, MVT keeps the
keeps the first command and logs a warning. The environment path of a CLI is first command and logs a warning. A collision from an explicit
registered before its installed packages, so a command loaded from there wins a `--load-command` is a usage error.
collision with a package. A collision from an explicit `--load-command` is a
usage error.
A package entry point or environment command that cannot be imported appears A package entry point or environment command that cannot be imported appears
as a marked broken command without preventing other MVT commands from working. as a marked broken command without preventing other MVT commands from working.
+60 -261
View File
@@ -35,61 +35,57 @@ class DependentModule(MVTModule):
prerequisite_results = self.get_dependency_results(PrerequisiteModule) prerequisite_results = self.get_dependency_results(PrerequisiteModule)
``` ```
Selecting a single module also runs its transitive dependencies. Selecting a single module also runs its transitive dependencies. If a dependency
is unavailable or the dependency graph contains a cycle, the command logs a
A module can only depend on modules the command it runs in also has. When a warning and does not run any modules.
declared dependency is not among them, the command logs a warning naming the
module and the missing dependency, skips that module and everything depending
on it, and runs the rest of the analysis. Selecting such a module with
`--module` therefore leaves nothing to run, which the warning explains.
A cycle in the dependency graph is a programming error rather than a
configuration problem: the command logs a warning and runs no modules at all.
## Parallel module execution
Extraction modules run concurrently, using four worker threads by default. Use
`--jobs INTEGER` to change the worker limit, or `--jobs 1` for sequential
execution and live per-line logging:
```bash
mvt-ios check-backup --jobs 8 --output ./out ./backup
```
The scheduler starts a module only after all of its declared dependencies have
completed. Results, alerts, and timeline entries are still aggregated in stable
topological order. During parallel execution, console records from each module
are buffered and printed together as a labeled block when that module finishes;
`command.log` is written immediately and retains the complete log stream.
Parallel-safe modules must log through `self.log`, must not write directly to
stdout or stderr, and must not mutate shared global state. A module that uses a
non-thread-safe resource or direct terminal output can opt out:
```python
class TerminalModule(MVTModule):
parallel_safe = False
```
Such modules run synchronously and exclusively after active workers finish.
## Custom modules ## Custom modules
MVT's module-running `check-*` commands can run forensic modules which are not Module-running `check-*` commands can load custom modules from Python files that
part of MVT. Custom modules are distributed as plugin packages. A Python are not installed as part of MVT. Load one file with:
package installed next to MVT registers its modules through an entry point.
The modules then load automatically in every command they support, see
[Installed module packages](#installed-module-packages). `mvt plugins list`
shows the installed packages and where each one was installed from.
MVT can also load module files by path, with `--load-module` and ```bash
`MVT_CUSTOM_MODULES`, see mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
[Developing modules locally](#developing-modules-locally). This can be used ```
while writing a module. Use a Python package to distribute one.
A custom module declares in `supported_commands` the platform and command You can also load a folder. MVT loads non-hidden top-level `*.py` files in
pairs it runs in. A module with empty `supported_commands` does not run and sorted order and skips `__init__.py`:
MVT logs a warning. The nine pairs are:
```bash
mvt-ios check-fs --load-module ./custom_modules ./filesystem-dump
```
Set `MVT_CUSTOM_MODULES` to load a folder for every module-running command. This
folder is loaded before any `--load-module` path:
```bash
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
```
Custom modules are normal `MVTModule` subclasses:
```python
from mvt.common.module import MVTModule
class ExampleCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"), ("ios", "check-fs"))
slug = "example_custom_module"
def run(self):
self.results = [{"message": "custom module ran"}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
Use `supported_commands` to declare the platform/command pairs a module
supports. Empty `supported_commands` means the module will not run and MVT logs
a warning. This explicit declaration is required for every command. Supported
pairs are:
```python ```python
("ios", "check-backup") ("ios", "check-backup")
@@ -103,88 +99,13 @@ MVT logs a warning. The nine pairs are:
("android", "check-iocs") ("android", "check-iocs")
``` ```
`check-iocs` re-checks stored results rather than an acquisition. It matches Custom modules can depend on existing MVT module classes. Dependencies are
every `<slug>.json` file in the results folder to the module with that slug. resolved with the same ordering logic as built-in modules, and custom modules
It then runs that module's `check_indicators()` again. A module which are appended after built-ins before ordering:
implements `check_indicators()` is included in `check-iocs` for its platform.
It does not need to declare the `check-iocs` pair.
### Writing a module
A module subclasses `MVTModule` or one of the base classes below and
implements `run()`. `check_indicators()` and `serialize()` are optional. The
first matches results against IOCs or detections. The second returns timeline
records.
```python ```python
from mvt.plugin import IOSExtraction, convert_unix_to_iso from mvt.common.module import MVTModule
from mvt.ios.modules.backup.manifest import Manifest
class ExampleCustomModule(IOSExtraction):
supported_commands = (
("ios", "check-backup"),
("ios", "check-fs"),
)
slug = "example_custom_module"
def run(self):
self.results = [{"checked_at": convert_unix_to_iso(0)}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
The base classes are:
- `MVTModule`: the base of every module. It provides `self.results`,
`self.alertstore`, `self.log`, `self.indicators` and
`get_dependency_results()`. Subclass it directly for a module which reads
only the results of other modules.
- `IOSExtraction`: `("ios", "check-backup")` and `("ios", "check-fs")`. Adds
`_find_ios_database()`, which locates a module's database in a backup or in
a filesystem dump and repairs it if it is malformed. Adds
`_get_backup_files_from_manifest()`, `_get_backup_file_from_id()` and
`_get_fs_files_from_patterns()`. Adds `_open_sqlite_db()`, which opens a
database read-only.
- `SysdiagnoseExtraction`: `("ios", "check-sysdiagnose")`. MVT extracts the
archive and calls `from_sysdiagnose_folder()` before `run()`. The module
reads files with `_get_files_by_pattern()` and `_get_file_content()`.
`ips_files` lists the crash reports. See
[Check an iOS Sysdiagnose](../ios/sysdiagnose.md).
- `AndroidQFModule`: `("android", "check-androidqf")`. MVT calls `from_dir()`
or `from_zip()` with the file list of the acquisition. The module reads
files with `_get_files_by_pattern()` and `_get_file_content()`.
`_get_device_timezone()` returns the device timezone.
- `AndroidBackupModule`: `("android", "check-backup")`. MVT calls `from_dir()`
or `from_ab()`. The module reads files with `_get_files_by_pattern()` and
`_get_file_content()`.
- `BugReportModule`: `("android", "check-bugreport")`. MVT calls `from_dir()`
or `from_zip()`. The module reads files with `_get_files_by_pattern()`,
`_get_files_by_patterns()` and `_get_file_content()`.
`_get_dumpstate_file()` returns the dumpstate file, and
`_get_file_modification_time()` the modification time of a file.
The underscore-named helpers are internal to the base classes. Plugin modules
can call them. Their names and signatures can change between releases. Read
the base class in `src/mvt/ios/modules` or `src/mvt/android/modules` before
relying on one.
### Depending on a built-in module
A module which post-processes records generated by one or more built-in MVT
modules must declare the source modules in `dependencies`. It reads their
results with `get_dependency_results()`. Import the class from its family
package: `mvt.ios.modules.backup`, `mvt.ios.modules.fs`,
`mvt.ios.modules.mixed`, `mvt.android.modules.androidqf`,
`mvt.android.modules.backup`, `mvt.android.modules.bugreport` or
`mvt.android.modules.intrusion_logs`.
```python
from mvt.ios.modules.backup import Manifest
from mvt.plugin import MVTModule
class DependentCustomModule(MVTModule): class DependentCustomModule(MVTModule):
@@ -196,85 +117,6 @@ class DependentCustomModule(MVTModule):
self.results = [{"manifest_entries": len(manifest_results)}] self.results = [{"manifest_entries": len(manifest_results)}]
``` ```
Dependencies are ordered as for the built-in modules, with custom modules
appended after the built-ins, see [Module dependencies](#module-dependencies).
A dependency has to run in every command the module supports. Where it does
not, MVT skips the module with a warning.
`get_dependency_results()` returns the plain dictionaries the module produced.
They are the same records it writes to `<slug>.json`. Typed results per module
are planned.
### Replacing a built-in module
A custom module which extends a built-in one runs alongside it, and when the
two share a slug they write to the same results file. MVT warns about that
collision, naming both modules, where each came from and the file the later one
overwrites, but it runs them both. Set `replaces` to the class the module
supersedes to take that module's place instead: when both are available to a
command, the named module is dropped from the run.
```python
from mvt.ios.modules.backup import Manifest as BuiltinManifest
class Manifest(BuiltinManifest):
supported_commands = (("ios", "check-backup"),)
replaces = BuiltinManifest
```
Keeping the class name of the replaced module, as above, keeps the name
`--module` selects the module by and the slug its results file is named after.
A replacement with a different class name writes to a file named after its own
slug, unless it sets `slug` to the slug of the module it replaces, and
`--module` still selects it by the name of the replaced module, which MVT logs.
Taking over the slug of a replaced module is not a collision and is not warned
about, because that module is no longer part of the run.
Modules which depend on the replaced module receive the replacement instead, so
`get_dependency_results(BuiltinManifest)` returns the replacement's results. A
module must not depend on the module it replaces: that module does not run, so
the replacement has to produce the data itself, and MVT logs a warning when a
module declares both.
A replacement takes on the obligations of the module it replaces. If it
declares a dependency the command does not provide, it is skipped like any
other module with an unavailable dependency, and the module it replaced stays
out of the run: neither of them produces results, and the modules depending on
the replaced module are skipped as well.
Subclassing the replaced module is not required, but a replacement which is not
a subclass is logged with a warning, because its results may not be what the
modules depending on the replaced module expect. Naming a module the command
does not run has no effect, and modules which replace each other in a cycle all
keep running and replace nothing. Every applied substitution is logged, so it
is recorded in `command.log` when the command runs with an `--output` folder.
Every command resolves replacements on its own. `check-iocs` matches stored
results files against the slugs of the modules available for that command. A
replacement which subclasses the module it replaces inherits its
`check_indicators()`. It is then part of `check-iocs` for its platform and
re-checks the results file named after its slug. A replacement with no
`check_indicators()` is not part of `check-iocs`. The built-in module it
replaced re-checks the file. `check-iocs` matches `--module` on the class name
only. Pass a differently named replacement's own name there, not the name of
the module it replaces.
### Importing from MVT
Import from `mvt.plugin` if it has what you need. The names it exports are
kept working on a best-effort basis. A change to one of them is announced in
the release notes. Anything else in `mvt` can be imported too, but may change
between releases without notice. The plugin interface is best effort.
`mvt.plugin` exports the base classes above and `Command`, `Alert` and
`AlertLevel`, the result types, `DatabaseNotFoundError` and
`DatabaseCorruptedError`, the timestamp converters, the settings API of
[Plugin Configuration](plugin_configuration.md), MVT's own `settings`,
`get_plugin_logger()` and `MVT_VERSION`. `src/mvt/plugin.py` holds the list.
Read MVT's `settings` for values such as `NETWORK_ACCESS_ALLOWED` and
`NETWORK_TIMEOUT`. Plugin values go in the plugin's own settings file.
## Installed module packages ## Installed module packages
Python packages can register modules so they load automatically in every Python packages can register modules so they load automatically in every
@@ -284,14 +126,14 @@ the package's `pyproject.toml`:
```toml ```toml
[project.entry-points."mvt.modules"] [project.entry-points."mvt.modules"]
mvt-plugin-example-org = "mvt_plugin_example_org:get_modules" mvt-plugin-amnesty-custom = "mvt_plugin_amnesty_custom:get_modules"
``` ```
The entry point must resolve to an iterable of `MVTModule` subclasses, or to The entry point must resolve to an iterable of `MVTModule` subclasses, or to
a callable returning one: a callable returning one:
```python ```python
from mvt.plugin import MVTModule from mvt.common.module import MVTModule
class PackagedModule(MVTModule): class PackagedModule(MVTModule):
@@ -305,10 +147,6 @@ def get_modules() -> list[type[MVTModule]]:
return [PackagedModule] return [PackagedModule]
``` ```
`get_modules()` is the package's module list, written by hand. A package which
keeps its modules in separate files imports each class there and lists it. A
module missing from the list does not load.
Installed modules follow the same rules as other custom modules: each module Installed modules follow the same rules as other custom modules: each module
must declare `supported_commands`, and dependencies are resolved with the must declare `supported_commands`, and dependencies are resolved with the
standard ordering logic. A broken entry point is skipped with a warning and standard ordering logic. A broken entry point is skipped with a warning and
@@ -319,20 +157,16 @@ from sources you trust.
For a `pipx` installation of MVT, inject the package into MVT's environment: For a `pipx` installation of MVT, inject the package into MVT's environment:
```bash ```bash
pipx inject mvt mvt-plugin-example-org pipx inject mvt mvt-plugin-amnesty-custom
``` ```
Module packages that need their own settings, such as an API key, should store
them in a namespaced [plugin configuration file](plugin_configuration.md)
rather than in MVT's own `config.yaml`.
### Naming module packages ### Naming module packages
Name module packages `mvt-plugin-<name>` (import package `mvt_plugin_<name>`), Name module packages `mvt-plugin-<name>` (import package `mvt_plugin_<name>`),
and include the name of the publishing organization or author so packages from and include the name of the publishing organization or author so packages from
different groups do not collide: for example, an organisation's custom modules different groups do not collide: for example, Amnesty International's custom
would be distributed as `mvt-plugin-example-org` with the import package modules would be distributed as `mvt-plugin-amnesty-custom` with the import
`mvt_plugin_example_org`. package `mvt_plugin_amnesty_custom`.
The prefix makes module packages easy to find on PyPI and keeps their import The prefix makes module packages easy to find on PyPI and keeps their import
names from clashing with unrelated Python packages. It is a convention, not a names from clashing with unrelated Python packages. It is a convention, not a
@@ -351,42 +185,11 @@ came from. MVT's own modules log under their dotted path (for example
MVT's internal logger tree: MVT's internal logger tree:
- Installed packages log under `mvt.ext.<package>`, with the `mvt_plugin_` - Installed packages log under `mvt.ext.<package>`, with the `mvt_plugin_`
prefix stripped: modules in `mvt_plugin_example_org` log as prefix stripped: modules in `mvt_plugin_amnesty_custom` log as
`mvt.ext.example_org.*`. `mvt.ext.amnesty_custom.*`.
- Files loaded with `--load-module` or `MVT_CUSTOM_MODULES` log as - Files loaded with `--load-module` or `MVT_CUSTOM_MODULES` log as
`mvt.ext.<file name>`. `mvt.ext.<file name>`.
Outside a module class, for example in a custom command line handler, log
through `get_plugin_logger(__name__)`. It returns a logger in the same
namespace.
## Developing modules locally
While a module is being written, load it from its file. `--load-module` takes a
Python file, or a folder of them, on every module-running command, and can be
repeated:
```bash
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
```
For a folder, MVT loads its non-hidden top-level `*.py` files in sorted order
and skips `__init__.py`. `MVT_CUSTOM_MODULES` names a folder to load on every
module-running command, before any `--load-module` path:
```bash
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
```
Files loaded this way follow the same rules as packaged modules.
`--list-modules` reports them with the SHA-256 hash of the file in place of a
version. An editable install of the package (`pip install -e .`) also works:
the modules load through the entry point, and `mvt plugins list` shows the
package with the `local` origin.
Loading by path is for development. Move a module into a package once it
works.
## Auditing loaded modules ## Auditing loaded modules
Because installed module packages load automatically, MVT records where every Because installed module packages load automatically, MVT records where every
@@ -400,17 +203,13 @@ module came from:
- When a command runs with an `--output` folder, the `command.log` file - When a command runs with an `--output` folder, the `command.log` file
records one line per module source with the source's version or hash and records one line per module source with the source's version or hash and
the list of modules loaded from it. the list of modules loaded from it.
- `mvt plugins list` lists the installed packages, where each of them
was installed from and how many modules it contributes, see
[Managing Plugins](plugins.md).
## Profiling ## Profiling
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
take to avoid inefficient code paths as we add new modules. take to avoid inefficient code paths as we add new modules.
MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_PROFILE` environment variable. Profiling forces sequential execution even MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_PROFILE` environment variable.
when `--jobs` is greater than one.
```bash ```bash
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
-157
View File
@@ -1,157 +0,0 @@
# Plugin Configuration
Plugin packages that add [custom CLI commands](custom_commands.md) or
[modules](index.md#installed-module-packages) often need to store their
own settings, such as an API key, a server URL or the timestamp of the last
synchronization. MVT provides a namespaced settings base class so each plugin
keeps its configuration in its own file, and a data folder for anything else a
plugin needs to keep on disk.
!!! warning
Do not write plugin settings to MVT's own `config.yaml`. MVT rewrites that
file with the settings it knows about every time it starts, so any other
section is deleted.
## Where Settings Are Stored
Each plugin gets one YAML file in a `plugins` folder next to MVT's own
configuration:
```
~/.config/mvt/plugins/<plugin name>.yaml
```
The exact parent folder follows the platform convention used for MVT's
`config.yaml` (for example `~/Library/Application Support/mvt` on macOS). Use
`plugin_config_path()` from `mvt.plugin` instead of building the path by hand.
Plugin names must be lowercase and may only contain letters, digits and dashes,
matching the `mvt-plugin-<name>` package naming convention. MVT creates the
`plugins` folder with `0700` permissions and writes the settings files with
`0600` permissions, because they commonly hold credentials. Files are written
through a temporary file and moved into place, so an interrupted save never
leaves a partially written settings file behind.
## Plugin Data Folder
Everything else a plugin keeps on disk, such as a cache, a downloaded artifact
or synchronization state, belongs in the folder returned by the `data_folder()`
class method of the plugin's settings class, or by `plugin_data_folder()` from
`mvt.plugin`, called with the plugin name if the plugin has no settings class:
```
~/.local/share/mvt/plugin-data/<plugin name>/ # Linux
~/Library/Application Support/mvt/plugin-data/<plugin name>/ # macOS
```
The folder sits beside MVT's own data, such as the downloaded indicators. It is
created if it is missing, with `0700` permissions. Asking for it again returns
the same path and leaves the contents alone, so a plugin can ask for it every
time it needs the folder. `ExamplePluginSettings` below is the settings class
defined in the next section:
```python
import os
def cache_path() -> str:
folder = ExamplePluginSettings.data_folder()
return os.path.join(folder, "virustotal_lookups_cache.json")
```
A plugin which has no settings class calls
`plugin_data_folder("example-plugin")` instead.
Do not fall back on a path of your own such as `~/.cache/example-plugin`: it
is a Linux-only convention, and MVT will not create it for you.
## Defining Plugin Settings
Subclass `MVTPluginSettings`, set `plugin_name` and declare typed fields with
defaults:
```python
from typing import Optional
from mvt.plugin import MVTPluginSettings
class ExamplePluginSettings(MVTPluginSettings):
plugin_name = "example-plugin"
API_KEY: Optional[str] = None
MAX_RESULTS: int = 25
LAST_SYNC: Optional[str] = None
```
`load()` returns the current settings and `save()` writes them back:
```python
from datetime import datetime, timezone
import click
from mvt.plugin import plugin_env_prefix
def sync():
settings = ExamplePluginSettings.load()
if not settings.API_KEY:
prefix = plugin_env_prefix(settings.plugin_name)
raise click.ClickException(
f"No API key configured. Set {prefix}API_KEY or "
"run 'example-plugin configure'."
)
settings.LAST_SYNC = datetime.now(timezone.utc).isoformat()
settings.save()
```
The message builds the variable name with `plugin_env_prefix()`, see
[Environment Variables](#environment-variables).
A missing settings file is not an error: the plugin then runs on the field
defaults and on whatever the environment provides. `save()` only persists the
values that differ from the defaults, and it never touches MVT's `config.yaml`.
A settings file that cannot be parsed, or that does not hold a mapping of
setting names to values, raises a `PluginConfigLoadError` naming the file.
Every subclass that sets its own `plugin_name` gets its own file and its own
environment namespace. A subclass that does not redefine `plugin_name` inherits
it, and therefore shares the file and the environment variables of its parent
class.
## Environment Variables
Every field can also be set with an environment variable. The prefix is
`MVT_PLUGIN_`, followed by the plugin name upper-cased with dashes replaced by
underscores, followed by the field name. For the example above:
```bash
export MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY=...
export MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS=50
```
Do not repeat the plugin name in a field name: a plugin named `example-scanner`
with a `SCANNER_API_KEY` field asks the user for
`MVT_PLUGIN_EXAMPLE_SCANNER_SCANNER_API_KEY`. Name the field `API_KEY`.
Settings resolve in this order, from highest to lowest priority:
1. Arguments passed to the settings class directly, such as
`ExamplePluginSettings(API_KEY="...")`
2. Environment variables
3. The plugin's YAML file
4. The field defaults declared on the settings class
!!! tip
On shared or multi-user machines, prefer passing API keys through
environment variables rather than saving them to the plugin file. `save()`
skips every value that the environment currently supplies, so a credential
provided that way is not copied into the settings file when a plugin saves
an unrelated setting.
Unknown keys in a plugin's YAML file are ignored, so a settings file written by
a newer version of a plugin does not break an older one.
-116
View File
@@ -1,116 +0,0 @@
# Managing Plugins
Plugin packages extend MVT with additional
[forensic modules](index.md#custom-modules) and
[CLI commands](custom_commands.md). Because installed packages load
automatically, `mvt plugins` audits what is installed and checks whether
updates are available. The command lives on `mvt` only, although the packages
it lists extend `mvt-ios` and `mvt-android` too.
## List Installed Plugins
```bash
mvt plugins list
```
```
Installed MVT plugins
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┓
┃ Name ┃ Version ┃ Origin ┃ Modules ┃ Commands ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━┩
│ mvt-plugin-example │ 1.2.0 │ pypi │ 4 │ summarize │
│ mvt-plugin-research │ 0.1.0 │ git+3f9a1c7d │ 2 │ - │
│ mvt-plugin-local │ 0.0.1 │ local │ 1 │ triage │
└─────────────────────┴─────────┴──────────────┴─────────┴───────────┘
```
The origin records where each package was installed from: `pypi` for a package
installed from a package index, `git+<commit>` for a package installed directly
from a repository, and `local` for a package installed from a local folder or
archive rather than from an index, including an editable development install.
The last two columns show how many forensic modules the package contributes and
which CLI commands it adds.
A plugin whose modules cannot be imported is listed with `error` in the
`Modules` column rather than breaking the listing.
## Check for Updates
```bash
mvt plugins check-updates
```
```
Plugin updates available:
mvt-plugin-example 1.2.0 → 1.3.0
Upgrade with: pip install -U mvt-plugin-example
MVT does not install plugin updates. Run the command above when you decide to
upgrade.
```
Packages installed from a package index are compared against the latest release
published for them. A package which was never published, for example a plugin
distributed only within an organization, is skipped silently.
!!! note
Packages shown with the `pypi` origin are compared against
[PyPI](https://pypi.org), whichever index they were installed from. A
plugin installed from a private index under a name which also exists on
PyPI is therefore compared against the unrelated public package of that
name. Give plugins published to a private index a name which is not taken
on PyPI, and treat an unexpected update suggestion as a reason to check
where the package would come from.
!!! warning
MVT never installs or upgrades a plugin itself, it only prints the command
which does. Upgrading a plugin in the middle of an investigation changes
the modules producing the results, and a plugin runs as trusted code inside
the MVT process, so pulling in a new version is a decision for the analyst
to make deliberately and not a side effect of running a check.
## Automatic Update Checks
MVT also reports available plugin updates in the banner printed when a command
starts:
```
MVT - Mobile Verification Toolkit
https://mvt.re
Version: 2026.7.29
Plugin updates available:
mvt-plugin-example 1.2.0 → 1.3.0 (pip install -U mvt-plugin-example)
```
This check runs at most once every 12 hours. In between checks MVT prints the
findings of the latest check without contacting anything, so a plugin update
stays visible without a lookup on every command. The
`mvt plugins check-updates` command checks immediately, regardless of when the
last check happened.
The automatic check is skipped when the `--disable-update-check` option is
used, when `NETWORK_ACCESS_ALLOWED` is disabled in the MVT configuration, and
when no plugins are installed.
## Plugins Installed From a Repository
A plugin installed with `pip install "mvt-plugin-example @ git+<url>"` is
checked by asking the remote repository which commit the installed revision
points at now. MVT runs git and ssh in batch mode, so a repository which needs
credentials MVT does not already have fails the check instead of prompting for
them. The check is skipped silently when git is not available, when the
repository cannot be reached, and when access to it is denied.
How the plugin was installed decides what an update means:
- A plugin installed from a branch is reported as outdated when the branch has
moved past the installed commit.
- A plugin installed from a specific commit or a tag is pinned. It is never
reported as outdated, however far the branch it came from moves on.
Pinning a plugin to a commit or a tag is therefore the way to keep the modules
used across an investigation stable.
+4 -25
View File
@@ -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). 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).
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/). 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. 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 ```bash
mkdir -p "$PWD/cases" docker run -it \
docker run --rm -it \
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \ --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 ghcr.io/mvt-project/mvt
``` ```
Inside the container, verify connectivity: If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
```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
View File
@@ -9,7 +9,7 @@ Mobile Verification Toolkit (MVT) is a tool to facilitate the [consensual forens
It has been developed and released by the [Amnesty International Security Lab](https://securitylab.amnesty.org) in July 2021 in the context of the [Pegasus Project](https://forbiddenstories.org/about-the-pegasus-project/) along with [a technical forensic methodology](https://www.amnesty.org/en/latest/research/2021/07/forensic-methodology-report-how-to-catch-nso-groups-pegasus/). It continues to be maintained by Amnesty International and other contributors. It has been developed and released by the [Amnesty International Security Lab](https://securitylab.amnesty.org) in July 2021 in the context of the [Pegasus Project](https://forbiddenstories.org/about-the-pegasus-project/) along with [a technical forensic methodology](https://www.amnesty.org/en/latest/research/2021/07/forensic-methodology-report-how-to-catch-nso-groups-pegasus/). It continues to be maintained by Amnesty International and other contributors.
In this documentation you will find instructions on how to install and run the `mvt-ios`, `mvt-android` and `mvt` commands, and guidance on how to interpret the extracted results. In this documentation you will find instructions on how to install and run the `mvt-ios` and `mvt-android` commands, and guidance on how to interpret the extracted results.
## Resources ## Resources
+4 -4
View File
@@ -1,6 +1,6 @@
# Installation # 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 ## Dependencies on Linux
@@ -64,7 +64,7 @@ It is recommended to try installing and running MVT from [Windows Subsystem Linu
pipx install mvt pipx install mvt
``` ```
You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window. You now should have the `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window.
### Installing from PyPI directly into a virtual environment ### Installing from PyPI directly into a virtual environment
You can use `pipenv`, `poetry` etc. for your virtual environment, but the provided example is with the built-in `venv` tool: You can use `pipenv`, `poetry` etc. for your virtual environment, but the provided example is with the built-in `venv` tool:
@@ -84,7 +84,7 @@ source env/bin/activate
pip install mvt pip install mvt
``` ```
The `mvt`, `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active. The `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active.
### Installing from git source with pipx ### Installing from git source with pipx
If you want to have the latest features in development, you can install MVT directly from the source code in git. If you want to have the latest features in development, you can install MVT directly from the source code in git.
@@ -93,7 +93,7 @@ If you want to have the latest features in development, you can install MVT dire
pipx install --force git+https://github.com/mvt-project/mvt.git pipx install --force git+https://github.com/mvt-project/mvt.git
``` ```
You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed. You now should have the `mvt-ios` and `mvt-android` utilities installed.
**Notes:** **Notes:**
1. The `--force` flag is necessary to force the reinstallation of the package. 1. The `--force` flag is necessary to force the reinstallation of the package.
+1 -1
View File
@@ -71,7 +71,7 @@ So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis
- [This repository](https://github.com/Te-k/stalkerware-indicators) contains IOCs for Android stalkerware including [a STIX MVT-compatible file](https://raw.githubusercontent.com/Te-k/stalkerware-indicators/master/generated/stalkerware.stix2). - [This repository](https://github.com/Te-k/stalkerware-indicators) contains IOCs for Android stalkerware including [a STIX MVT-compatible file](https://raw.githubusercontent.com/Te-k/stalkerware-indicators/master/generated/stalkerware.stix2).
- We are also maintaining [a list of IOCs](https://github.com/mvt-project/mvt-indicators) in STIX format from public spyware campaigns. - We are also maintaining [a list of IOCs](https://github.com/mvt-project/mvt-indicators) in STIX format from public spyware campaigns.
You can automatically download the latest public indicator files with the command `mvt download-iocs`. The per-platform forms `mvt-ios download-iocs` and `mvt-android download-iocs` do the same thing. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT. You can automaticallly download the latest public indicator files with the command `mvt-ios download-iocs` or `mvt-android download-iocs`. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT.
Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs. Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs.
+17 -1
View File
@@ -1,3 +1,19 @@
# Backup with libimobiledevice # 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/
```
-37
View File
@@ -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
View File
@@ -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. 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:
## Installation
Install `pipx` following the [MVT installation instructions](../install.md), then run:
```bash ```bash
pipx install pymobiledevice3 sudo apt install libimobiledevice-utils
``` ```
To update an existing installation: On Mac, you can try installing it from brew:
```bash ```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 ```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 ```bash
pymobiledevice3 usbmux list sudo apt remove --purge libimobiledevice-utils libimobiledevice-dev libimobiledevice6 libplist-dev libplist3 libusbmuxd-dev libusbmuxd-tools libusbmuxd4 libusbmuxd6 usbmuxd
pymobiledevice3 lockdown info
``` ```
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 ```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.
-12
View File
@@ -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*. 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` ### `os_analytics_ad_daily.json`
@@ -439,11 +435,3 @@ This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extr
This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup. This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup.
---
## Records extracted by `check-sysdiagnose`
### `sysdiagnose_info.json`
This JSON file is created by mvt-ios' `SysdiagnoseInfo` module. The module extracts details about the device and the sysdiagnose itself: the product type and model, iOS version and build, serial number, IMEI, MEID and UDID from the remotectl dump state and the mobile activation request, the Apple account name and email from the App Store daemon database (no longer part of a sysdiagnose on newer iOS versions, still read from older archives), and the original file name and creation time of the archive from *sysdiagnose.log*.
+15 -22
View File
@@ -1,35 +1,30 @@
# Check an iOS Sysdiagnose # Check an iOS Sysdiagnose
`mvt-ios check-sysdiagnose` analyzes an iOS sysdiagnose archive. MVT's own `mvt-ios check-sysdiagnose` prepares an iOS sysdiagnose archive for analysis by
`SysdiagnoseInfo` module extracts details about the device and the archive custom MVT modules. MVT does not include built-in sysdiagnose modules. You must
(see [`sysdiagnose_info.json`](records.md#sysdiagnose_infojson)); the checks load at least one custom module that explicitly supports this command.
come from the modules of the installed
[plugin packages](../development/index.md#installed-module-packages) which
declare support for the command. Without any such module the command still
records the device details, and warns that no forensic sysdiagnose modules
have been loaded so that the run cannot pass for a clean analysis.
The command accepts either an extracted sysdiagnose directory or the original The command accepts either an extracted sysdiagnose directory or the original
gzip-compressed tar archive. gzip-compressed tar archive.
```bash ```bash
mvt-ios check-sysdiagnose --output ./results \ mvt-ios check-sysdiagnose \
--load-module ./sysdiagnose_modules.py \
--output ./results \
./sysdiagnose_2024.01.02_03-04-05+0200.tar.gz ./sysdiagnose_2024.01.02_03-04-05+0200.tar.gz
``` ```
Use `--hashes` to include hashes for analyzed files in `info.json`, and Use `--hashes` to include hashes for analyzed files in `info.json`, and
`--list-modules` to display the eligible modules without running them. `--list-modules` to display the eligible custom modules without running them.
## Writing a custom module ## Writing a custom module
Extend `SysdiagnoseExtraction` from `mvt.plugin`, see Extend `SysdiagnoseExtraction` to access the archive contents consistently for
[Writing a module](../development/index.md#writing-a-module). The module reads both directory and tar inputs. Each module must declare the command explicitly
the archive the same way whether MVT was given a folder or a tar archive. It in `supported_commands`.
declares the command in `supported_commands`. While writing one,
[load it from its file](../development/index.md#developing-modules-locally).
```python ```python
from mvt.plugin import SysdiagnoseExtraction from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
class ExampleSysdiagnoseModule(SysdiagnoseExtraction): class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
@@ -49,9 +44,7 @@ class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
return None return None
``` ```
MVT extracts a tar archive first. It calls `from_sysdiagnose_folder()` on each The base class provides `from_sysdiagnose_folder()` and
module before `run()`. `ips_files` lists the IPS crash reports. `from_sysdiagnose_tar()` setup hooks, as well as protected file lookup, file
reading, and timezone extraction helpers. IPS crash-report metadata is exposed
`_get_files_by_pattern()` and `_get_file_content()` are internal helpers of the on `ips_files`.
base class. Use them to read the archive. Their names and signatures can change
between releases. See `src/mvt/ios/modules/sysdiagnose/base.py`.
+2 -5
View File
@@ -5,7 +5,6 @@ copyright: Copyright &copy; 2021-2023 MVT Project Developers
site_description: Mobile Verification Toolkit Documentation site_description: Mobile Verification Toolkit Documentation
not_in_nav: | not_in_nav: |
android/adb.md android/adb.md
ios/backup/libimobiledevice.md
markdown_extensions: markdown_extensions:
- attr_list - attr_list
- admonition - admonition
@@ -35,10 +34,10 @@ nav:
- Using Docker: "docker.md" - Using Docker: "docker.md"
- MVT for iOS: - MVT for iOS:
- iOS Forensic Methodology: "ios/methodology.md" - iOS Forensic Methodology: "ios/methodology.md"
- Install pymobiledevice3: "ios/install.md" - Install libimobiledevice: "ios/install.md"
- Check an iTunes Backup: - Check an iTunes Backup:
- Backup with iTunes app: "ios/backup/itunes.md" - 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 Backup with mvt-ios: "ios/backup/check.md"
- Check a Filesystem Dump: - Check a Filesystem Dump:
- Dumping the filesystem: "ios/filesystem/dump.md" - Dumping the filesystem: "ios/filesystem/dump.md"
@@ -53,6 +52,4 @@ nav:
- Development: - Development:
- Development Instructions: "development/index.md" - Development Instructions: "development/index.md"
- Custom CLI Commands: "development/custom_commands.md" - Custom CLI Commands: "development/custom_commands.md"
- Plugin Configuration: "development/plugin_configuration.md"
- Managing Plugins: "development/plugins.md"
- License: "license.md" - License: "license.md"
+8 -9
View File
@@ -24,8 +24,9 @@ dependencies = [
"simplejson==4.1.1", "simplejson==4.1.1",
"packaging==26.3", "packaging==26.3",
"appdirs==1.4.4", "appdirs==1.4.4",
"iphone_backup_decrypt==0.10.0", "iOSbackup==0.9.925",
"pycryptodome>=3.20.0", "adb-shell[usb]==0.4.4",
"libusb1==3.4.0",
"cryptography==50.0.0", "cryptography==50.0.0",
"PyYAML>=6.0.2", "PyYAML>=6.0.2",
"pyahocorasick==2.3.1", "pyahocorasick==2.3.1",
@@ -43,9 +44,8 @@ homepage = "https://docs.mvt.re/en/latest/"
repository = "https://github.com/mvt-project/mvt" repository = "https://github.com/mvt-project/mvt"
[project.scripts] [project.scripts]
mvt = "mvt.cli:main" mvt-ios = "mvt.ios:cli"
mvt-ios = "mvt.ios:main" mvt-android = "mvt.android:cli"
mvt-android = "mvt.android:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -71,7 +71,7 @@ docs = [
] ]
[build-system] [build-system]
requires = ["setuptools>=61.0", "setuptools-scm>=8"] requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.coverage.run] [tool.coverage.run]
@@ -120,6 +120,5 @@ where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
mvt = ["ios/data/*.json"] mvt = ["ios/data/*.json"]
[tool.setuptools_scm] [tool.setuptools.dynamic]
# The version is the latest v* tag; ignore the archive/* tags. version = { attr = "mvt.common.version.MVT_VERSION" }
git_describe_command = "git describe --dirty --tags --long --match 'v*'"
+1 -1
View File
@@ -3,4 +3,4 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .cli import cli, main from .cli import cli
@@ -4,51 +4,13 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import re import re
from typing import Any
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
class DumpsysAccessibilityArtifact(AndroidArtifact): class DumpsysAccessibilityArtifact(AndroidArtifact):
# One list for both record shapes — a service record and a count-only
# record must stay the same shape.
_FIELDS = (
"user_id",
"component",
"package_name",
"service_name",
"installed",
"enabled",
"binding",
"bound",
"crashed",
"accessibility_tool",
"installed_service_count",
"unnamed_service_count",
)
def check_indicators(self) -> None: def check_indicators(self) -> None:
for result in self.results: for result in self.results:
# A stated count the dump does not back with component names is a
# coverage statement, not a service: low, but not silent.
if not result.get("component"):
stated = result["installed_service_count"]
unnamed = result["unnamed_service_count"]
if unnamed == stated:
detail = "does not list their component names"
else:
detail = (
f"lists the component names of only {stated - unnamed} "
f"of them ({unnamed} unnamed)"
)
self.alertstore.low(
f"The accessibility dump states {stated} installed "
f"service(s) for user {result['user_id']} but {detail}",
"",
result,
)
continue
if self.indicators: if self.indicators:
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc_match = self.indicators.check_app_id(result["package_name"])
if ioc_match: if ioc_match:
@@ -57,22 +19,11 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
) )
continue continue
# Installed is not enabled. A service can sit installed for years self.alertstore.medium(
# without ever being switched on, and that is the difference this f'Found accessibility service: "{result["service"]}"',
# channel is read for — an alert that says only "found" makes every "",
# device look equally exposed. A service the dump says is switched result,
# OFF is reported LOW, so it still reaches the analyst without )
# competing with one that is actually running; a dump that does not
# state the enabled state stays MEDIUM, because "not stated" is not
# "not enabled".
message = (
f'Found accessibility service: "{result["component"]}" '
f"({self._describe_state(result)})"
)
if result.get("enabled") is False and not result.get("bound"):
self.alertstore.low(message, "", result)
else:
self.alertstore.medium(message, "", result)
def parse(self, content: str) -> None: def parse(self, content: str) -> None:
""" """
@@ -82,122 +33,41 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
:param content: content of the accessibility section (string) :param content: content of the accessibility section (string)
""" """
self.results: list[dict[str, Any]] = [] # "Old" syntax
services: dict[tuple[int | None, str], dict] = {} in_services = False
# Which state sections the dump printed, per user: one user's printed for line in content.splitlines():
# `enabled services` says nothing about another user's. if line.strip().startswith("installed services:"):
seen_states: dict[int | None, set[str]] = {} in_services = True
# `installedServiceCount=N` from the user's `attributes:{…}` line is on continue
# most builds the only statement about installed services in the dump:
# few print the `installed services: {…}` block. if not in_services:
installed_counts: dict[int | None, int] = {} continue
user_id: int | None = None
state: str | None = None if line.strip() == "}":
# At end of installed services
break
service = line.split(":")[1].strip()
self.results.append(
{
"package_name": service.split("/")[0],
"service": service,
}
)
# "New" syntax - AOSP >= 14 (?)
# Looks like:
# Enabled services:{{com.azure.authenticator/com.microsoft.brooklyn.module.accessibility.BrooklynAccessibilityService}, {com.agilebits.onepassword/com.agilebits.onepassword.filling.accessibility.FillingAccessibilityService}}
for line in content.splitlines(): for line in content.splitlines():
user_match = re.search(r"attributes:\{id=(\d+)", line) if line.strip().startswith("Enabled services:"):
if user_match: matches = re.finditer(r"{([^{]+?)}", line)
user_id = int(user_match.group(1))
count_match = re.search(r"installedServiceCount=(\d+)", line) for match in matches:
if count_match: # Each match is in format: <package_name>/<service>
installed_counts[user_id] = int(count_match.group(1)) package_name, _, service = match.group(1).partition("/")
stripped = line.strip() self.results.append(
state_match = re.match( {"package_name": package_name, "service": service}
r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)",
stripped,
)
if state_match:
state = state_match.group(1).lower()
seen_states.setdefault(user_id, set()).add(self._state_field(state))
inline = state_match.group(2)
for component in re.findall(
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
):
service = services.setdefault(
(user_id, component), self._new_service(component, user_id)
) )
service[self._state_field(state)] = True
service["accessibility_tool"] = "(A11yTool)" in inline
continue
if not state:
continue
if stripped == "}" or stripped.startswith("AccessibilityInputFilter"):
state = None
continue
component_match = re.search(
r"(?:\d+\s*:\s*)?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?",
stripped,
)
if component_match:
component = component_match.group(1)
service = services.setdefault(
(user_id, component), self._new_service(component, user_id)
)
service[self._state_field(state)] = True
service["accessibility_tool"] = "(A11yTool)" in stripped
# A section that was never printed is NOT the same as one printed
# empty: the first says nothing, the second says nothing is enabled.
# Defaulting every flag to False would turn "not stated" into "not
# enabled". Flags for sections this dump never printed stay None.
named: dict[int | None, int] = {}
for (service_user, _component), service in services.items():
printed = seen_states.get(service_user, set())
for state in ("installed", "enabled", "binding", "bound", "crashed"):
if self._state_field(state) not in printed:
service[self._state_field(state)] = None
service["installed_service_count"] = installed_counts.get(service_user)
named[service_user] = named.get(service_user, 0) + 1
self.results.extend(services.values())
# A stated count the named services do not add up to would leave no
# trace of the rest: the module would log "a total of 0" about a dump
# that said five, or "a total of 1" about one that said two.
for count_user, count in installed_counts.items():
unnamed = count - named.get(count_user, 0)
if unnamed > 0:
self.results.append(self._new_unlisted(count_user, count, unnamed))
@staticmethod
def _describe_state(result: dict) -> str:
if result.get("bound"):
return "enabled and bound"
if result.get("enabled"):
return "enabled"
if result.get("enabled") is None:
return "installed, enabled state not stated"
return "installed, not enabled"
@staticmethod
def _state_field(state: str) -> str:
return {"binding": "binding", "bound": "bound"}.get(state, state)
@staticmethod
def _new_unlisted(user_id: int | None, count: int, unnamed: int) -> dict:
"""The dump's own count for a user, and how many of those services it
did not name.
Every other field stays unknown: the dump named no service to carry it.
"""
record: dict[str, Any] = dict.fromkeys(DumpsysAccessibilityArtifact._FIELDS)
record["user_id"] = user_id
record["installed_service_count"] = count
record["unnamed_service_count"] = unnamed
return record
@staticmethod
def _new_service(component: str, user_id: int | None) -> dict:
record: dict[str, Any] = dict.fromkeys(
DumpsysAccessibilityArtifact._FIELDS, False
)
record["user_id"] = user_id
record["component"] = component
record["package_name"], record["service_name"] = component.split("/", 1)
# Filled in after parsing: the count is per user.
record["installed_service_count"] = None
record["unnamed_service_count"] = None
return record
+7 -95
View File
@@ -6,7 +6,6 @@
import base64 import base64
import binascii import binascii
import hashlib import hashlib
import re
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
@@ -29,26 +28,11 @@ class DumpsysADBArtifact(AndroidArtifact):
stack = [res] stack = [res]
cur_indent = 0 cur_indent = 0
in_multiline = False in_multiline = False
for line in dump_data.strip(b"\r\n").split(b"\n"): for line in dump_data.strip(b"\n").split(b"\n"):
line = line.removesuffix(b"\r")
# Track the level of indentation # Track the level of indentation
indent = len(line) - len(line.lstrip()) indent = len(line) - len(line.lstrip())
# A closing brace is dedented from the block it closes, and the if indent < cur_indent:
# 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 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
# well-formed block the parser expects. Stop here rather than
# raise IndexError out of the module on the next line.
self.log.error(
"Unexpected indentation in ADB manager state, "
"stopping the parse of this section"
)
break
stack.pop() stack.pop()
cur_indent = indent cur_indent = indent
else: else:
@@ -77,16 +61,7 @@ class DumpsysADBArtifact(AndroidArtifact):
stack.pop() stack.pop()
current_dict = stack[-1] current_dict = stack[-1]
if not line.strip():
continue
if key == "}": if key == "}":
if len(stack) <= 1:
self.log.error(
"Unbalanced closing brace in ADB manager state, "
"stopping the parse of this section"
)
break
stack.pop() stack.pop()
continue continue
@@ -123,34 +98,6 @@ class DumpsysADBArtifact(AndroidArtifact):
return keystore return keystore
def parse_binary_xml(self, data: bytes) -> list[dict]:
"""Recover ADB key records from Android binary XML (ABX).
Some dumpstate implementations embed ABX in a text stream and replace
binary token bytes. The public key remains intact, while unavailable
numeric metadata is represented as ``None`` rather than corrupt text.
"""
keystore = []
seen = set()
for match in re.finditer(
rb"(?<![A-Za-z0-9+/])([A-Za-z0-9+/]{300,}={0,2})"
rb"(?: ([A-Za-z0-9_.@-]+))?",
data,
):
key = match.group(1)
try:
base64.b64decode(key, validate=True)
except (binascii.Error, ValueError):
continue
if key in seen:
continue
seen.add(key)
full_key = key + (b" " + match.group(2) if match.group(2) else b"")
key_info = self.calculate_key_info(full_key)
key_info["last_connected"] = None
keystore.append(key_info)
return keystore
@staticmethod @staticmethod
def calculate_key_info(user_key: bytes) -> dict: def calculate_key_info(user_key: bytes) -> dict:
if b" " in user_key: if b" " in user_key:
@@ -171,7 +118,7 @@ class DumpsysADBArtifact(AndroidArtifact):
return { return {
"user": user.decode("utf-8"), "user": user.decode("utf-8"),
"fingerprint": key_fingerprint_colon, "fingerprint": key_fingerprint_colon,
"key": key_base64.decode("ascii", errors="replace"), "key": key_base64,
} }
def check_indicators(self) -> None: def check_indicators(self) -> None:
@@ -185,26 +132,6 @@ class DumpsysADBArtifact(AndroidArtifact):
f"'{user_key['fingerprint']}'" f"'{user_key['fingerprint']}'"
) )
@staticmethod
def _find_state_end(content: bytes, open_brace: int) -> int:
"""Find the unindented line closing the ADB manager state, or -1.
Braces inside key comments or embedded keystore data are values, while
nested structural closing braces are indented.
"""
line_start = open_brace
while line_start < len(content):
line_end = content.find(b"\n", line_start)
if line_end == -1:
line_end = len(content)
line = content[line_start:line_end].removesuffix(b"\r")
if line_start > open_brace and line == b"}":
return line_start
if line.startswith((b"---------", b"DUMP OF SERVICE ")):
break
line_start = line_end + 1
return -1
def parse(self, content: bytes) -> None: def parse(self, content: bytes) -> None:
""" """
Parse the Dumpsys ADB section Parse the Dumpsys ADB section
@@ -224,16 +151,12 @@ class DumpsysADBArtifact(AndroidArtifact):
self.log.error("Unable to find ADB manager state in dumpsys output") self.log.error("Unable to find ADB manager state in dumpsys output")
return return
end_of_json = self._find_state_end(content, start_of_json + 1) end_of_json = content.rfind(b"}\n")
if end_of_json == -1: if end_of_json == -1 or end_of_json <= start_of_json:
self.log.error( self.log.error("Unable to find complete ADB manager state in dumpsys output")
"Unable to find complete ADB manager state in dumpsys output"
)
return return
# The brace that opens the state and the one that closes it are not part json_content = content[start_of_json + 2 : end_of_json - 2].rstrip()
# of the indented body.
json_content = content[start_of_json + 2 : end_of_json].rstrip()
parsed = self.indented_dump_parser(json_content) parsed = self.indented_dump_parser(json_content)
if parsed.get("debugging_manager") is None: if parsed.get("debugging_manager") is None:
@@ -252,23 +175,12 @@ class DumpsysADBArtifact(AndroidArtifact):
# Keystore is in XML format on some devices and we need to parse it # Keystore is in XML format on some devices and we need to parse it
if keystore_data and keystore_data.startswith(b"<?xml"): if keystore_data and keystore_data.startswith(b"<?xml"):
parsed["debugging_manager"]["keystore"] = self.parse_xml(keystore_data) parsed["debugging_manager"]["keystore"] = self.parse_xml(keystore_data)
elif keystore_data and keystore_data.startswith(b"ABX\x00"):
parsed["debugging_manager"]["keystore"] = self.parse_binary_xml(
keystore_data
)
else: else:
# Keystore is not XML format # Keystore is not XML format
parsed["debugging_manager"]["keystore"] = keystore_data parsed["debugging_manager"]["keystore"] = keystore_data
parsed = parsed["debugging_manager"] parsed = parsed["debugging_manager"]
for key, value in list(parsed.items()):
if isinstance(value, bytes):
decoded = value.decode("utf-8", errors="replace")
parsed[key] = (
decoded == "true" if decoded in ("true", "false") else decoded
)
# Calculate key fingerprints for better readability # Calculate key fingerprints for better readability
key_info = [] key_info = []
for user_key in parsed.get("user_keys", []): for user_key in parsed.get("user_keys", []):
+81 -122
View File
@@ -4,7 +4,6 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from datetime import datetime from datetime import datetime
import re
from typing import Any from typing import Any
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
@@ -28,14 +27,14 @@ class DumpsysAppopsArtifact(AndroidArtifact):
continue continue
for entry in perm["entries"]: for entry in perm["entries"]:
if entry.get("timestamp"): if "timestamp" in entry:
records.append( records.append(
{ {
"timestamp": entry["timestamp"], "timestamp": entry["timestamp"],
"module": self.__class__.__name__, "module": self.__class__.__name__,
"event": entry["event"], "event": entry["access"],
"data": f"{result['package_name']} access to " "data": f"{result['package_name']} access to "
f"{perm['name']}: {entry['event']}", f"{perm['name']}: {entry['access']}",
} }
) )
@@ -52,7 +51,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
continue continue
# We use a placeholder entry to create a basic alert even without permission entries. # We use a placeholder entry to create a basic alert even without permission entries.
placeholder_entry = {"event": "unknown", "timestamp": ""} placeholder_entry = {"access": "Unknown", "timestamp": ""}
for perm in result["permissions"]: for perm in result["permissions"]:
if ( if (
@@ -61,12 +60,12 @@ class DumpsysAppopsArtifact(AndroidArtifact):
): ):
for entry in sorted( for entry in sorted(
perm["entries"] or [placeholder_entry], perm["entries"] or [placeholder_entry],
key=lambda x: x.get("timestamp") or "", key=lambda x: x["timestamp"],
): ):
cleaned_result = result.copy() cleaned_result = result.copy()
cleaned_result["permissions"] = [perm] cleaned_result["permissions"] = [perm]
self.alertstore.medium( self.alertstore.medium(
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['event']}' at {entry['timestamp']}", f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
entry["timestamp"], entry["timestamp"],
cleaned_result, cleaned_result,
) )
@@ -74,151 +73,111 @@ class DumpsysAppopsArtifact(AndroidArtifact):
elif result["package_name"] in RISKY_PACKAGES: elif result["package_name"] in RISKY_PACKAGES:
for entry in sorted( for entry in sorted(
perm["entries"] or [placeholder_entry], perm["entries"] or [placeholder_entry],
key=lambda x: x.get("timestamp") or "", key=lambda x: x["timestamp"],
): ):
cleaned_result = result.copy() cleaned_result = result.copy()
cleaned_result["permissions"] = [perm] cleaned_result["permissions"] = [perm]
self.alertstore.medium( self.alertstore.medium(
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['event']}' at {entry['timestamp']}", f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
entry["timestamp"], entry["timestamp"],
cleaned_result, cleaned_result,
) )
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
self.results: list[dict[str, Any]] = [] # self.results: List[Dict[str, Any]] = []
permission: dict[str, Any] | None = None perm: dict[str, Any] = {}
package: dict[str, Any] | None = None package: dict[str, Any] = {}
uid: str | None = None entry: dict[str, Any] = {}
uid_details: dict[str, Any] = {} uid = None
attribution: str | None = None
in_packages = False in_packages = False
def finish_permission() -> None:
nonlocal permission
if package is not None and permission is not None:
package["permissions"].append(permission)
permission = None
def finish_package() -> None:
nonlocal package
finish_permission()
if package is not None:
self.results.append(package)
package = None
for line in output.splitlines(): for line in output.splitlines():
uid_match = re.match(r"^ Uid ([^:]+):$", line) if line.startswith(" Uid 0:"):
if uid_match:
in_packages = True in_packages = True
finish_package()
uid = uid_match.group(1)
uid_details = {
"uid_state": None,
"capability": None,
"app_widget_visible": None,
"default_modes": {},
}
continue
if not in_packages: if not in_packages:
continue continue
uid_property = re.match( if line.startswith(" Uid "):
r"^ (state|capability|appWidgetVisible)=(.*)$", line uid = line[6:-1]
) if entry:
if uid_property: perm["entries"].append(entry)
key = { entry = {}
"state": "uid_state", if package:
"appWidgetVisible": "app_widget_visible", if perm:
}.get(uid_property.group(1), uid_property.group(1)) package["permissions"].append(perm)
value: Any = uid_property.group(2)
if value in ("true", "false"):
value = value == "true"
uid_details[key] = value
continue
default_mode = re.match(r"^ ([A-Z0-9_]+): mode=([^\s]+)", line) perm = {}
if default_mode and package is None: self.results.append(package)
uid_details["default_modes"][default_mode.group(1)] = ( package = {}
default_mode.group(2)
)
continue continue
if line.startswith(" Package "): if line.startswith(" Package "):
finish_package() if entry:
perm["entries"].append(entry)
entry = {}
if package:
if perm:
package["permissions"].append(perm)
perm = {}
self.results.append(package)
package = { package = {
"package_name": line[12:-1], "package_name": line[12:-1],
"permissions": [], "permissions": [],
"uid": uid, "uid": uid,
**uid_details,
} }
continue continue
operation_match = re.match( if package and line.startswith(" ") and line[6] != " ":
r"^ ([A-Z0-9_]+)(?: \(([^)]+)\))?:\s*$", line if entry:
) perm["entries"].append(entry)
if package is not None and operation_match: entry = {}
finish_permission() if perm:
permission = { package["permissions"].append(perm)
"name": operation_match.group(1), perm = {}
"mode": operation_match.group(2),
"entries": [], perm["name"] = line.split()[0]
} perm["entries"] = []
attribution = None if len(line.split()) > 1:
perm["access"] = line.split()[1][1:-2]
continue continue
attribution_match = re.match(r"^\s{8,}([^=]+)=\[$", line) if line.startswith(" "):
if attribution_match: # Permission entry like:
attribution = attribution_match.group(1).strip() # Reject: [fg-s]2021-05-19 22:02:52.054 (-314d1h25m2s33ms)
continue access_type = line.split(":")[0].strip()
if line.strip() == "]": if access_type not in ["Access", "Reject"]:
attribution = None # Skipping invalid access type. Some entries are not in the format we expect
continue continue
if permission is None: if entry:
continue perm["entries"].append(entry)
event_match = re.match( entry = {}
r"^\s*(Access|Reject):\s*\[([^]]+)\]\s*"
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s*" entry["access"] = access_type
r"(\([^)]*\))?(?:\s+duration=([^\s]+))?", entry["type"] = line[line.find("[") + 1 : line.find("]")]
line,
) try:
running_match = re.match( entry["timestamp"] = convert_datetime_to_iso(
r"^\s*Running start at:\s*(\S+(?: \S+)?)", datetime.strptime(
line, line[line.find("]") + 1 : line.find("(")].strip(),
) "%Y-%m-%d %H:%M:%S.%f",
if event_match: )
entry = {
"event": event_match.group(1).lower(),
"access": event_match.group(1),
"uid_state": event_match.group(2),
"timestamp": convert_datetime_to_iso(
datetime.strptime(event_match.group(3), "%Y-%m-%d %H:%M:%S.%f")
),
"relative_time": event_match.group(4),
"duration": event_match.group(5),
"attribution": attribution,
}
permission["entries"].append(entry)
elif running_match:
raw_start = running_match.group(1)
timestamp = None
if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", raw_start):
timestamp = convert_datetime_to_iso(
datetime.strptime(raw_start, "%Y-%m-%d %H:%M:%S.%f")
) )
permission["entries"].append( except ValueError:
{ # Invalid date format
"event": "running", pass
"access": "Running",
"uid_state": None,
"timestamp": timestamp,
"relative_time": raw_start
if raw_start.startswith("+")
else None,
"duration": None,
"attribution": attribution,
}
)
finish_package() if line.strip() == "":
break
if entry:
perm["entries"].append(entry)
if perm:
package["permissions"].append(perm)
if package:
self.results.append(package)
@@ -18,18 +18,18 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
action = record.get("action", "update") action = record.get("action", "update")
package_name = record["package_name"] package_name = record["package_name"]
vers = record["version_code"] vers = record["vers"]
if vers == 0: if vers == "0":
data = f"Recorded uninstall of package {package_name} (vers 0)" data = f"Recorded uninstall of package {package_name} (vers 0)"
elif action == "downgrade": elif action == "downgrade":
prev_vers = record.get("previous_version_code", "unknown") prev_vers = record.get("previous_vers", "unknown")
data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}" data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}"
else: else:
data = f"Recorded update of package {package_name} with vers {vers}" data = f"Recorded update of package {package_name} with vers {vers}"
return { return {
"timestamp": record["period_start"], "timestamp": record["from"],
"module": self.__class__.__name__, "module": self.__class__.__name__,
"event": "battery_daily", "event": "battery_daily",
"data": data, "data": data,
@@ -48,7 +48,6 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
continue continue
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
self.results = []
daily = None daily = None
daily_updates: list[dict[str, Any]] = [] daily_updates: list[dict[str, Any]] = []
records: list[dict[str, Any]] = [] records: list[dict[str, Any]] = []
@@ -60,10 +59,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
timeframe = line[13:].strip() timeframe = line[13:].strip()
date_from, date_to = timeframe.strip(":").split(" to ", 1) date_from, date_to = timeframe.strip(":").split(" to ", 1)
daily = { daily = {"from": date_from[0:10], "to": date_to[0:10]}
"period_start": self._format_daily_timestamp(date_from),
"period_end": self._format_daily_timestamp(date_to),
}
continue continue
if not daily: if not daily:
@@ -73,35 +69,22 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
continue continue
line = line.strip().replace("Update ", "") line = line.strip().replace("Update ", "")
# A truncated or vendor-specific line must not abort the parse and package_name, vers = line.split(" ", 1)
# lose every record after it. vers_nr = vers.split("=", 1)[1]
package_name, _, vers = line.partition(" ")
vers_raw = vers.partition("=")[2]
if not package_name or not vers_raw:
continue
try:
version_code: int | str = int(vers_raw)
except ValueError:
version_code = vers_raw
already_seen = False already_seen = False
for update in daily_updates: for update in daily_updates:
if ( if package_name == update["package_name"] and vers_nr == update["vers"]:
package_name == update["package_name"]
and version_code == update["version_code"]
):
update["occurrences"] += 1
already_seen = True already_seen = True
break break
if not already_seen: if not already_seen:
update_record: dict[str, Any] = { update_record: dict[str, Any] = {
"action": "update", "action": "update",
"period_start": daily["period_start"], "from": daily["from"],
"period_end": daily["period_end"], "to": daily["to"],
"package_name": package_name, "package_name": package_name,
"version_code": version_code, "vers": vers_nr,
"occurrences": 1,
} }
daily_updates.append(update_record) daily_updates.append(update_record)
@@ -112,31 +95,26 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
self._detect_uninstalls_and_downgrades(records) self._detect_uninstalls_and_downgrades(records)
self.results.extend(records) self.results.extend(records)
@staticmethod def _detect_uninstalls_and_downgrades(
def _format_daily_timestamp(value: str) -> str: self, records: list[dict[str, Any]]
if len(value) >= 19 and value[10] == "-": ) -> None:
return f"{value[:10]} {value[11:].replace('-', ':')}"
return value
def _detect_uninstalls_and_downgrades(self, records: list[dict[str, Any]]) -> None:
package_versions: dict[str, int] = {} package_versions: dict[str, int] = {}
for record in sorted( for record in sorted(
records, records,
key=lambda record: ( key=lambda record: (
record["period_start"], record["from"],
record["period_end"], record["to"],
record["package_name"], record["package_name"],
), ),
): ):
package_name = record["package_name"] package_name = record["package_name"]
vers_nr = record["version_code"] vers_nr = record["vers"]
if vers_nr == 0: if vers_nr == "0":
record["action"] = "uninstall"
self.alertstore.medium( self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)", f"Detected uninstall of package {package_name} (vers 0)",
record["period_start"], record["from"],
record, record,
) )
package_versions.pop(package_name, None) package_versions.pop(package_name, None)
@@ -150,11 +128,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
previous_vers = package_versions.get(package_name) previous_vers = package_versions.get(package_name)
if previous_vers is not None and current_vers < previous_vers: if previous_vers is not None and current_vers < previous_vers:
record["action"] = "downgrade" record["action"] = "downgrade"
record["previous_version_code"] = previous_vers record["previous_vers"] = str(previous_vers)
self.alertstore.medium( self.alertstore.medium(
f"Detected downgrade of package {package_name} " f"Detected downgrade of package {package_name} "
f"from vers {previous_vers} to vers {current_vers}", f"from vers {previous_vers} to vers {current_vers}",
record["period_start"], record["from"],
record, record,
) )
@@ -1,177 +1,96 @@
# Mobile Verification Toolkit (MVT) # Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors. # Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import datetime
import re
from mvt.common.utils import convert_datetime_to_iso
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
class DumpsysBatteryHistoryArtifact(AndroidArtifact): class DumpsysBatteryHistoryArtifact(AndroidArtifact):
"""Parser for package-related events in dumpsys batterystats history.""" """
Parser for dumpsys dattery history events.
"""
def check_indicators(self) -> None: def check_indicators(self) -> None:
if not self.indicators: if not self.indicators:
return return
for result in self.results: for result in self.results:
package_name = result.get("package_name") ioc_match = self.indicators.check_app_id(result["package_name"])
if not package_name:
continue
ioc_match = self.indicators.check_app_id(package_name)
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc ioc_match.message, "", result, matched_indicator=ioc_match.ioc
) )
continue
@staticmethod
def _parse_wall_time(value: str) -> datetime.datetime | None:
if re.fullmatch(r"\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", value):
value = f"1900-{value}"
for date_format in (
"%Y-%m-%d-%H-%M-%S-%f",
"%Y-%m-%d-%H-%M-%S",
"%Y-%m-%d %H:%M:%S.%f",
):
try:
return datetime.datetime.strptime(value, date_format)
except ValueError:
pass
return None
@staticmethod
def _elapsed_seconds(value: str) -> float | None:
if not value.startswith("+"):
return None
units = {"d": 86400, "h": 3600, "m": 60, "s": 1, "ms": 0.001}
total = 0.0
for number, unit in re.findall(r"(\d+)(ms|d|h|m|s)", value):
total += int(number) * units[unit]
return total
@staticmethod
def _package_from_name(name: str) -> str | None:
clean = name.removeprefix("*walarm*:").removeprefix("*alarm*:")
slash_parts = clean.split("/")
if len(slash_parts) > 1:
first = slash_parts[0].lstrip("@")
if first.startswith(("com.", "org.", "net.")):
return first
for part in reversed(slash_parts[1:]):
candidate = part.lstrip("@").split(":", 1)[0]
if candidate.startswith(("com.", "org.", "net.")):
return candidate
parts = clean.split(".")
package_parts = []
for part in parts:
if part and part[0].islower():
package_parts.append(part)
else:
break
return ".".join(package_parts) if len(package_parts) >= 2 else None
@staticmethod
def _normalize_service(service: str) -> str:
# WorkManager decorates jobs with one or more scheduler prefixes.
if "@" in service:
candidates = [part for part in service.split("@") if "/" in part]
if candidates:
return candidates[-1]
return service
def parse(self, data: str) -> None: def parse(self, data: str) -> None:
self.results: list[dict[str, str | None]] = []
anchor_time: datetime.datetime | None = None
anchor_elapsed = 0.0
has_history_heading = any(
line.startswith("Battery History") for line in data.splitlines()
)
in_history = not has_history_heading
for line in data.splitlines(): for line in data.splitlines():
stripped = line.strip() if line.startswith("Battery History "):
if line.startswith("Battery History"):
if in_history:
break
in_history = True
continue continue
if not in_history:
continue if line.strip() == "":
if has_history_heading and not stripped:
break break
reset_match = re.search(r"(?:RESET:)?TIME:\s*(\S+)", stripped)
if reset_match:
parsed_time = self._parse_wall_time(reset_match.group(1))
if parsed_time is not None:
elapsed_token = stripped.split()[0]
anchor_elapsed = self._elapsed_seconds(elapsed_token) or 0.0
anchor_time = parsed_time
continue
fields = stripped.split() time_parts = line.strip().split()
if not fields: time_elapsed = time_parts[0]
continue if (
if len(fields) > 1 and re.fullmatch(r"\d{2}-\d{2}", fields[0]): len(time_parts) > 1
time_elapsed = " ".join(fields[:2]) and len(time_parts[0]) == 5
line_time = self._parse_wall_time(time_elapsed) and time_parts[0][2] == "-"
elapsed = None and ":" in time_parts[1]
):
time_elapsed = " ".join(time_parts[:2])
event = ""
if line.find("+job") > 0:
event = "start_job"
payload = line.split("+job=", 1)[1]
uid, separator, service = payload.partition(":")
if not separator:
continue
service = service.strip().strip('"')
package_name = service.split("/")[0]
elif line.find("-job") > 0:
event = "end_job"
payload = line.split("-job=", 1)[1]
uid, separator, service = payload.partition(":")
if not separator:
continue
service = service.strip().strip('"')
package_name = service.split("/")[0]
elif line.find("+running +wake_lock=") > 0:
payload = line.split("+running +wake_lock=", 1)[1]
uid, separator, _ = payload.partition(":")
if not separator:
continue
event = "wake"
service = (
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
)
if service == "" or "/" not in service:
continue
package_name = service.split("/")[0]
elif (line.find("+top=") > 0) or (line.find("-top") > 0):
if line.find("+top=") > 0:
event = "start_top"
top_pos = line.find("+top=")
else:
event = "end_top"
top_pos = line.find("-top=")
colon_pos = top_pos + line[top_pos:].find(":")
uid = line[top_pos + 5 : colon_pos]
service = ""
package_name = line[colon_pos + 1 :].strip('"')
else: else:
time_elapsed = fields[0] continue
elapsed = self._elapsed_seconds(time_elapsed)
line_time = None
timestamp = line_time self.results.append(
if timestamp is None and anchor_time is not None and elapsed is not None: {
timestamp = anchor_time + datetime.timedelta( "time_elapsed": time_elapsed,
seconds=elapsed - anchor_elapsed "event": event,
) "uid": uid,
"package_name": package_name,
def add( "service": service,
event: str, uid: str, service: str, package_name: str | None }
) -> None: )
self.results.append(
{
"time_elapsed": time_elapsed,
"timestamp": convert_datetime_to_iso(timestamp)
if timestamp
else None,
"event": event,
"uid": uid,
"package_name": package_name,
"service": service,
}
)
for sign, uid, raw_service in re.findall(
r"([+-])job=([^:\s]+):\"([^\"]+)\"", line
):
service = self._normalize_service(raw_service)
add(
"start_job" if sign == "+" else "end_job",
uid,
service,
self._package_from_name(service) or service.split("/", 1)[0],
)
for sign, uid, package_name in re.findall(
r"([+-])top=([^:\s]+):\"([^\"]+)\"", line
):
add(
"start_top" if sign == "+" else "end_top",
uid,
"",
package_name,
)
wake_match = re.search(r"\+wake_lock=([^:\s]+):\"([^\"]+)\"", line)
if wake_match:
wake_name = wake_match.group(2)
add(
"wake",
wake_match.group(1),
wake_name,
self._package_from_name(wake_name),
)
+11 -25
View File
@@ -29,27 +29,16 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
rxp = re.compile( rxp = re.compile(
r"^\s*\d+:\s*\[((?:\d{4}-)?\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]\s*" r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} "
r"(?:\[Pid:\((\d+)\)\])?\s*([\w-]+) took (\d+)ms - ([^,]+)," r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*"
r"\s*sql=\"(.*)\"(?:, path=(.*))?$" r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\""
) ) # pylint: disable=line-too-long
pool: str | None = None pool = None
connection_number: int | None = None
is_primary: bool | None = None
in_operations = False in_operations = False
for line in output.splitlines(): for line in output.splitlines():
if line.startswith("Connection pool for "): if line.startswith("Connection pool for "):
pool = line.replace("Connection pool for ", "").rstrip(":") pool = line.replace("Connection pool for ", "").rstrip(":")
in_operations = False
connection_match = re.match(r"\s+Connection #(\d+):", line)
if connection_match:
connection_number = int(connection_match.group(1))
is_primary = None
if line.strip().startswith("isPrimaryConnection:"):
is_primary = line.strip().split(":", 1)[1].strip() == "true"
if not pool: if not pool:
continue continue
@@ -63,6 +52,7 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
if not line.startswith(" "): if not line.startswith(" "):
in_operations = False in_operations = False
pool = None
continue continue
match = rxp.match(line) match = rxp.match(line)
@@ -70,15 +60,11 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
continue continue
result = { result = {
"timestamp": match.group(1), "isodate": match.group(1),
"pid": int(match.group(2)) if match.group(2) else None,
"action": match.group(3), "action": match.group(3),
"duration_ms": int(match.group(4)), "sql": match.group(4),
"status": match.group(5), "path": pool,
"sql": match.group(6),
"path": match.group(7) or pool,
"pool_path": pool,
"connection_number": connection_number,
"is_primary": is_primary,
} }
if match.group(2):
result["pid"] = match.group(2)
self.results.append(result) self.results.append(result)
@@ -4,7 +4,6 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
from .package_resolvers import parse_resolver_table
class DumpsysPackageActivitiesArtifact(AndroidArtifact): class DumpsysPackageActivitiesArtifact(AndroidArtifact):
@@ -20,11 +19,67 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
) )
continue continue
def parse(self, content: str) -> None: def parse(self, content: str):
""" """
Parse the Dumpsys Package section for activities Parse the Dumpsys Package section for activities
Adds results to self.results Adds results to self.results
:param content: content of the package section (string) :param content: content of the package section (string)
""" """
self.results = parse_resolver_table(content, "Activity") self.results = []
in_activity_resolver_table = False
in_non_data_actions = False
intent = None
for line in content.splitlines():
if line.startswith("Activity Resolver Table:"):
in_activity_resolver_table = True
continue
if not in_activity_resolver_table:
continue
if line.startswith(" Non-Data Actions:"):
in_non_data_actions = True
continue
if not in_non_data_actions:
continue
# If we hit an empty line, the Non-Data Actions section should be
# finished.
if line.strip() == "":
break
# We detect the action name.
if (
line.startswith(" " * 6)
and not line.startswith(" " * 8)
and ":" in line
):
intent = line.strip().replace(":", "")
continue
# If we are not in an intent block yet, skip.
if not intent:
continue
# If we are in a block but the line does not start with 8 spaces
# it means the block ended a new one started, so we reset and
# continue.
if not line.startswith(" " * 8):
intent = None
continue
# If we got this far, we are processing receivers for the
# activities we are interested in.
activity = line.strip().split(" ")[1]
package_name = activity.split("/")[0]
self.results.append(
{
"intent": intent,
"package_name": package_name,
"activity": activity,
}
)
+184 -206
View File
@@ -1,10 +1,10 @@
# Mobile Verification Toolkit (MVT) # Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors. # Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import re import re
from typing import Any from typing import Any, Dict, List, Optional
from mvt.android.utils import ROOT_PACKAGES from mvt.android.utils import ROOT_PACKAGES
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
@@ -12,244 +12,222 @@ from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
def _value(raw: str) -> Any:
if raw == "null":
return None
if raw in ("true", "false"):
return raw == "true"
try:
return int(raw)
except ValueError:
return raw
class DumpsysPackagesArtifact(AndroidArtifact): class DumpsysPackagesArtifact(AndroidArtifact):
def check_indicators(self) -> None: def check_indicators(self) -> None:
alerted_root_packages = set() alerted_root_packages = set()
for result in self.results: for result in self.results:
package_name = result["package_name"] if result["package_name"] in ROOT_PACKAGES:
if ( if result["package_name"] in alerted_root_packages:
package_name in ROOT_PACKAGES continue
and package_name not in alerted_root_packages alerted_root_packages.add(result["package_name"])
):
alerted_root_packages.add(package_name)
self.alertstore.medium( self.alertstore.medium(
f'Found an installed package related to rooting/jailbreaking: "{package_name}"', f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
"", "",
result, result,
) )
continue
if not self.indicators: if not self.indicators:
continue continue
ioc_match = self.indicators.check_app_id(package_name)
ioc_match = self.indicators.check_app_id(result.get("package_name", ""))
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc ioc_match.message, "", result, matched_indicator=ioc_match.ioc
) )
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
records = []
timestamps = [ timestamps = [
("package_install", record.get("timestamp")), {"event": "package_install", "timestamp": record["timestamp"]},
("package_last_update", record.get("last_update_time")),
]
timestamps.extend(
("package_first_install", user.get("first_install_time"))
for user in record.get("users", [])
)
return [
{ {
"timestamp": timestamp, "event": "package_first_install",
"module": self.__class__.__name__, "timestamp": record["first_install_time"],
"event": event, },
"data": f"Install or update of package {record['package_name']}", {"event": "package_last_update", "timestamp": record["last_update_time"]},
}
for event, timestamp in timestamps
if timestamp
] ]
for timestamp in timestamps:
records.append(
{
"timestamp": timestamp["timestamp"],
"module": self.__class__.__name__,
"event": timestamp["event"],
"data": f"Install or update of package {record['package_name']}",
}
)
return records
@staticmethod @staticmethod
def _permission(line: str, permission_type: str) -> dict: def parse_dumpsys_package_for_details(output: str) -> Dict[str, Any]:
name, _, details = line.strip().partition(":") """
granted_match = re.search(r"granted=(true|false)", details) Parse one entry of a dumpsys package information
flags_match = re.search(r"flags=\[\s*([^]]*)\]", details) """
return { details: Dict[str, Any] = {
"name": name, "uid": "",
"type": permission_type, "version_name": "",
"granted": granted_match.group(1) == "true" if granted_match else None, "version_code": "",
"flags": [ "timestamp": "",
flag.strip() "first_install_time": "",
for flag in (flags_match.group(1).split("|") if flags_match else []) "last_update_time": "",
if flag.strip() "installer": "",
],
}
@classmethod
def parse_dumpsys_package_for_details(cls, output: str) -> dict[str, Any]:
details: dict[str, Any] = {
"app_id": None,
"version_name": None,
"version_code": None,
"min_sdk": None,
"target_sdk": None,
"timestamp": None,
"last_update_time": None,
"installer": None,
"system": False, "system": False,
"permissions": [], "permissions": list(),
"requested_permissions": [], "requested_permissions": list(),
"users": [],
} }
permission_section: str | None = None in_install_permissions = False
current_user: dict[str, Any] | None = None in_runtime_permissions = False
legacy_first_install: str | None = None in_declared_permissions = False
in_requested_permissions = True
current_user: Optional[int] = None
first_install_times: Dict[Optional[int], str] = {}
runtime_permissions: Dict[Optional[int], List[Dict[str, Any]]] = {}
for line in output.splitlines(): for line in output.splitlines():
stripped = line.strip() user_match = re.match(r"User (\d+):", line.strip())
user_match = re.match(r"User (\d+):\s*(.*)", stripped)
if user_match: if user_match:
current_user = {"user_id": int(user_match.group(1)), "permissions": []} current_user = int(user_match.group(1))
for key, raw in re.findall(r"(\w+)=([^\s]+)", user_match.group(2)):
clean_key = {
"notLaunched": "not_launched",
"installReason": "install_reason",
"uninstallReason": "uninstall_reason",
"dataDir": "data_dir",
}.get(key, re.sub(r"(?<!^)(?=[A-Z])", "_", key).lower())
current_user[clean_key] = _value(raw)
details["users"].append(current_user)
permission_section = None
continue
header = stripped.lower() if in_install_permissions:
if header in { if line.startswith(" " * 4) and not line.startswith(" " * 6):
"declared permissions:", in_install_permissions = False
"install permissions:", else:
"requested permissions:", lineinfo = line.strip().split(":")
"runtime permissions:", permission = lineinfo[0]
}: granted = None
permission_section = header.split()[0] if "granted=" in lineinfo[1]:
continue granted = "granted=true" in lineinfo[1]
if current_user is not None: details["permissions"].append(
user_property = re.match( {"name": permission, "granted": granted, "type": "install"}
r"(installReason|uninstallReason|dataDir|firstInstallTime)=(.*)", )
stripped, if in_runtime_permissions:
) if not line.startswith(" " * 8):
if user_property: in_runtime_permissions = False
key = { else:
"installReason": "install_reason", lineinfo = line.strip().split(":")
"uninstallReason": "uninstall_reason", permission = lineinfo[0]
"dataDir": "data_dir", granted = None
"firstInstallTime": "first_install_time", if "granted=" in lineinfo[1]:
}[user_property.group(1)] granted = "granted=true" in lineinfo[1]
current_user[key] = _value(user_property.group(2))
continue
if permission_section == "requested" and line.startswith(" "): runtime_permissions.setdefault(current_user, []).append(
details["requested_permissions"].append(stripped) {"name": permission, "granted": granted, "type": "runtime"}
continue )
if permission_section in ("declared", "install") and line.startswith( if in_declared_permissions:
" " if not line.startswith(" " * 6):
): in_declared_permissions = False
details["permissions"].append( else:
cls._permission(stripped, permission_section) permission = line.strip().split(":")[0]
) details["permissions"].append(
continue {"name": permission, "type": "declared"}
if ( )
permission_section == "runtime" if in_requested_permissions:
and line.startswith(" ") if not line.startswith(" " * 6):
and current_user is not None in_requested_permissions = False
): else:
current_user["permissions"].append(cls._permission(stripped, "runtime")) details["requested_permissions"].append(line.strip())
continue if line.strip().startswith("userId="):
details["uid"] = line.split("=")[1].strip()
elif line.strip().startswith("versionName="):
details["version_name"] = line.split("=")[1].strip()
elif line.strip().startswith("versionCode="):
details["version_code"] = line.split("=", 1)[1].strip()
elif line.strip().startswith("timeStamp="):
details["timestamp"] = line.split("=")[1].strip()
elif line.strip().startswith("installerPackageName="):
details["installer"] = line.split("=", 1)[1].strip()
elif line.strip().startswith("pkgFlags="):
details["system"] = "SYSTEM" in line.split("=", 1)[1].split()
elif line.strip().startswith("firstInstallTime="):
first_install_times[current_user] = line.split("=", 1)[1].strip()
elif line.strip().startswith("lastUpdateTime="):
details["last_update_time"] = line.split("=")[1].strip()
elif line.strip() == "install permissions:":
in_install_permissions = True
elif line.strip() == "runtime permissions:":
in_runtime_permissions = True
elif line.strip() == "declared permissions:":
in_declared_permissions = True
elif line.strip() == "requested permissions:":
in_requested_permissions = True
simple_match = re.match( if 0 in first_install_times:
r"(appId|userId|versionName|timeStamp|lastUpdateTime|installerPackageName)=(.*)", details["first_install_time"] = first_install_times[0]
stripped, elif None in first_install_times:
) details["first_install_time"] = first_install_times[None]
if simple_match: elif first_install_times:
key = { details["first_install_time"] = next(iter(first_install_times.values()))
"appId": "app_id",
"userId": "app_id",
"versionName": "version_name",
"timeStamp": "timestamp",
"lastUpdateTime": "last_update_time",
"installerPackageName": "installer",
}[simple_match.group(1)]
raw_value = simple_match.group(2)
details[key] = (
_value(raw_value)
if key == "app_id" or raw_value == "null"
else raw_value
)
continue
if stripped.startswith("pkgFlags="):
details["system"] = "SYSTEM" in stripped.split("=", 1)[1].split()
continue
version_match = re.match(
r"versionCode=([^\s]+)(?:\s+minSdk=([^\s]+))?(?:\s+targetSdk=([^\s]+))?",
stripped,
)
if version_match:
details["version_code"] = _value(version_match.group(1))
details["min_sdk"] = (
_value(version_match.group(2)) if version_match.group(2) else None
)
details["target_sdk"] = (
_value(version_match.group(3)) if version_match.group(3) else None
)
elif stripped.startswith("firstInstallTime="):
legacy_first_install = stripped.split("=", 1)[1]
if legacy_first_install: if 0 in runtime_permissions:
user_zero = next( details["permissions"].extend(runtime_permissions[0])
(user for user in details["users"] if user["user_id"] == 0), None elif None in runtime_permissions:
) details["permissions"].extend(runtime_permissions[None])
if user_zero is None: elif runtime_permissions:
user_zero = {"user_id": 0, "permissions": []} details["permissions"].extend(next(iter(runtime_permissions.values())))
details["users"].append(user_zero)
user_zero.setdefault("first_install_time", legacy_first_install)
return details return details
def parse(self, content: str) -> None: def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]:
self.results: list[dict[str, Any]] = [] """
category: str | None = None Parse the dumpsys package service data
package: dict[str, Any] | None = None """
block: list[str] = [] pkg_rxp = re.compile(r" Package \[(.+?)\].*")
def finish() -> None: results = []
nonlocal package, block package_name = None
if package is not None: package = {}
package.update(self.parse_dumpsys_package_for_details("\n".join(block))) lines: list[str] = []
self.results.append(package) for line in output.splitlines():
package = None if line.startswith(" Package ["):
block = [] if len(lines) > 0:
details = self.parse_dumpsys_package_for_details("\n".join(lines))
package.update(details)
results.append(package)
lines = []
package = {}
matches = pkg_rxp.findall(line)
if not matches:
continue
package_name = matches[0]
package["package_name"] = package_name
continue
if not package_name:
continue
lines.append(line)
if len(lines) > 0:
details = self.parse_dumpsys_package_for_details("\n".join(lines))
package.update(details)
results.append(package)
return results
def parse(self, content: str):
"""
Parse the Dumpsys Package section for activities
Adds results to self.results
:param content: content of the package section (string)
"""
self.results = []
package = []
in_package_list = False
for line in content.splitlines(): for line in content.splitlines():
if line == "Packages:": if line.startswith("Packages:"):
finish() in_package_list = True
category = "active"
continue continue
if line == "Hidden system packages:":
finish() if not in_package_list:
category = "hidden_system"
continue continue
package_match = re.match(r"^ Package \[([^]]+)\]", line)
if package_match and category: if line.strip() == "":
finish() break
package = {
"package_name": package_match.group(1), package.append(line)
"package_type": category,
} self.results = self.parse_dumpsys_packages("\n".join(package))
continue
if (
category
and line
and not line.startswith(" ")
and not line.endswith(" overlay paths:")
):
finish()
category = None
continue
if package is not None:
block.append(line)
finish()
@@ -3,8 +3,6 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import re
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
@@ -27,37 +25,19 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
def parse(self, data: str) -> None: def parse(self, data: str) -> None:
for line in data.splitlines(): for line in data.splitlines():
match = re.match(r"ChangeId\((\d+);\s*(.*)\)$", line.strip()) if not line.startswith("ChangeId(168419799; name=DOWNSCALED;"):
if not match or "rawOverrides={" not in line:
continue continue
body = match.group(2)
name_match = re.search(r"(?:^|;\s*)name=([^;]+)", body) if line.strip() == "":
state = ( break
"enabled"
if re.search(r"(?:^|;\s*)enabled(?:;|$)", body) # Look for rawOverrides field
else "disabled" if "rawOverrides={" in line:
) # Extract the content inside the braces for rawOverrides
overridable = bool(re.search(r"(?:^|;\s*)overridable(?:;|$)", body)) overrides_field = line.split("rawOverrides={", 1)[1].split("};", 1)[0]
overrides_field = body.split("rawOverrides={", 1)[1].split("}", 1)[0]
for entry in overrides_field.split(","): for entry in overrides_field.split(", "):
package_name, separator, raw_value = entry.strip().partition("=") # Extract app name
if not separator: uninstall_app = entry.split("=")[0].strip()
continue
value: bool | int | str self.results.append({"package_name": uninstall_app})
if raw_value in ("true", "false"):
value = raw_value == "true"
else:
try:
value = int(raw_value)
except ValueError:
value = raw_value
self.results.append(
{
"change_id": int(match.group(1)),
"change_name": name_match.group(1) if name_match else None,
"change_state": state,
"overridable": overridable,
"package_name": package_name,
"override_value": value,
}
)
+108 -39
View File
@@ -4,7 +4,6 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
from .package_resolvers import parse_resolver_table
INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS" INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS"
INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED" INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED"
@@ -19,45 +18,115 @@ class DumpsysReceiversArtifact(AndroidArtifact):
""" """
def check_indicators(self) -> None: def check_indicators(self) -> None:
for receiver in self.results: for intent, receivers in self.results.items():
intent = receiver["key"] for receiver in receivers:
if intent == INTENT_NEW_OUTGOING_SMS: if intent == INTENT_NEW_OUTGOING_SMS:
self.log.info( self.log.info(
'Found a receiver to intercept outgoing SMS messages: "%s"', 'Found a receiver to intercept outgoing SMS messages: "%s"',
receiver["component"], receiver["receiver"],
) )
elif intent == INTENT_SMS_RECEIVED: elif intent == INTENT_SMS_RECEIVED:
self.log.info( self.log.info(
'Found a receiver to intercept incoming SMS messages: "%s"', 'Found a receiver to intercept incoming SMS messages: "%s"',
receiver["component"], receiver["receiver"],
) )
elif intent == INTENT_DATA_SMS_RECEIVED: elif intent == INTENT_DATA_SMS_RECEIVED:
self.log.info( self.log.info(
'Found a receiver to intercept incoming data SMS message: "%s"', 'Found a receiver to intercept incoming data SMS message: "%s"',
receiver["component"], receiver["receiver"],
) )
elif intent == INTENT_PHONE_STATE: elif intent == INTENT_PHONE_STATE:
self.log.info( self.log.info(
'Found a receiver monitoring telephony state/incoming calls: "%s"', "Found a receiver monitoring "
receiver["component"], 'telephony state/incoming calls: "%s"',
) receiver["receiver"],
elif intent == INTENT_NEW_OUTGOING_CALL: )
self.log.info( elif intent == INTENT_NEW_OUTGOING_CALL:
'Found a receiver monitoring outgoing calls: "%s"', self.log.info(
receiver["component"], 'Found a receiver monitoring outgoing calls: "%s"',
) receiver["receiver"],
)
if not self.indicators: if not self.indicators:
continue continue
ioc_match = self.indicators.check_app_id(receiver["package_name"]) ioc_match = self.indicators.check_app_id(receiver["package_name"])
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, ioc_match.message,
"", "",
receiver, {intent: receiver},
matched_indicator=ioc_match.ioc, matched_indicator=ioc_match.ioc,
) )
continue
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
self.results = parse_resolver_table(output, "Receiver") self.results: dict[str, list[dict[str, str]]] = {}
in_receiver_resolver_table = False
in_non_data_actions = False
intent = None
for line in output.splitlines():
if line.startswith("Receiver Resolver Table:"):
in_receiver_resolver_table = True
continue
if not in_receiver_resolver_table:
continue
if line.startswith(" Non-Data Actions:"):
in_non_data_actions = True
continue
if not in_non_data_actions:
continue
# If we hit an empty line, the Non-Data Actions section should be
# finished.
if line.strip() == "":
break
# We detect the action name.
if (
line.startswith(" " * 6)
and not line.startswith(" " * 8)
and ":" in line
):
intent = line.strip().replace(":", "")
self.results[intent] = []
continue
parts = line.strip().split(" ")
if len(parts) < 2:
# A single-token line here is not a receiver. Real dumpstate
# output can print an action header mis-indented (observed with
# 15 leading spaces instead of 6), which used to raise
# IndexError and abort the whole module. Treat a trailing-colon
# token as the next action, skip anything else.
if parts[0].endswith(":"):
intent = parts[0][:-1]
self.results.setdefault(intent, [])
continue
# If we are not in an intent block yet, skip.
if not intent:
continue
# If we are in a block but the line does not start with 8 spaces
# it means the block ended a new one started, so we reset and
# continue.
if not line.startswith(" " * 8):
intent = None
continue
# If we got this far, we are processing receivers for the
# activities we are interested in.
receiver = parts[1]
package_name = receiver.split("/")[0]
self.results[intent].append(
{
"package_name": package_name,
"receiver": receiver,
}
)
+12 -9
View File
@@ -28,16 +28,19 @@ INTERESTING_PROPERTIES = [
class GetProp(AndroidArtifact): class GetProp(AndroidArtifact):
def parse(self, entry: str) -> None: def parse(self, entry: str) -> None:
self.results: List[Dict[str, str]] = [] self.results: List[Dict[str, str]] = []
# A property value may span several lines: persist.sys.boot.reason.history rxp = re.compile(r"\[(.+?)\]: \[(.+?)\]")
# prints one boot per line. Matching the whole section instead of line by
# line lets a value run to the first closing bracket that ends a line.
rxp = re.compile(
r"^[ \t]*\[([^]]+)\]: \[(.*?)\][ \t\r]*$",
re.MULTILINE | re.DOTALL,
)
for name, value in rxp.findall(entry): for line in entry.splitlines():
self.results.append({"name": name, "value": value}) line = line.strip()
if line == "":
continue
matches = re.findall(rxp, line)
if not matches or len(matches[0]) != 2:
continue
prop_entry = {"name": matches[0][0], "value": matches[0][1]}
self.results.append(prop_entry)
def get_device_timezone(self) -> str | None: def get_device_timezone(self) -> str | None:
""" """
+1 -57
View File
@@ -117,55 +117,6 @@ class Mounts(AndroidArtifact):
# Skip lines that don't match expected format # Skip lines that don't match expected format
continue continue
@staticmethod
def parse_mountinfo(entry: str, process_id: int) -> list[dict[str, Any]]:
"""Parse Linux /proc/PID/mountinfo records."""
results = []
for line in entry.splitlines():
fields = line.split()
if "-" not in fields:
continue
separator = fields.index("-")
if separator < 6 or len(fields) < separator + 4:
continue
try:
mount_id = int(fields[0])
parent_id = int(fields[1])
except ValueError:
continue
mount_options = fields[5].split(",")
super_options = fields[separator + 3].split(",")
options = list(dict.fromkeys(mount_options + super_options))
# fields[5] are the per-mount (VFS) flags, the field after the
# separator the superblock's; a mount is writable only if both
# allow it. The merged list made every read-only overlay a HIGH.
is_read_write = "rw" in mount_options and "rw" in super_options
mount_point = fields[4].replace("\\040", " ")
device = fields[separator + 2].replace("\\040", " ")
filesystem_type = fields[separator + 1]
is_system = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
mount_point.startswith(f"{prefix}/")
for prefix in SUSPICIOUS_MOUNT_POINTS
)
results.append(
{
"mount_id": mount_id,
"parent_id": parent_id,
"major_minor": fields[2],
"root": fields[3].replace("\\040", " "),
"mount_point": mount_point,
"device": device,
"filesystem_type": filesystem_type,
"mount_options": ",".join(options),
"options_list": options,
"optional_fields": fields[6:separator],
"is_system_partition": is_system,
"is_read_write": is_read_write,
"process_ids": [process_id],
}
)
return results
def check_indicators(self) -> None: def check_indicators(self) -> None:
""" """
Check for suspicious mount configurations that may indicate root access Check for suspicious mount configurations that may indicate root access
@@ -195,14 +146,7 @@ class Mounts(AndroidArtifact):
) )
# Check for other suspicious mount options # Check for other suspicious mount options
# `rw` sitting in the merged option list is not evidence of suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS]
# writability (see is_read_write in parse_mountinfo); every other
# suspicious option is meaningful in either list.
suspicious_opts = [
opt
for opt in options
if opt in SUSPICIOUS_OPTIONS and (opt != "rw" or mount["is_read_write"])
]
if suspicious_opts and mount["is_system_partition"]: if suspicious_opts and mount["is_system_partition"]:
if ( if (
"noatime" in mount["mount_options"] "noatime" in mount["mount_options"]
@@ -1,70 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import re
RESOLVER_TYPES = {
"Full MIME Types": "full_mime_type",
"Base MIME Types": "base_mime_type",
"Wild MIME Types": "wild_mime_type",
"Schemes": "scheme",
"Non-Data Actions": "non_data_action",
"MIME Typed Actions": "mime_typed_action",
}
ENTRY_RE = re.compile(
r"^\s{8,}[0-9a-fA-F]+\s+(?P<component>\S+)"
r"(?:\s+\((?P<filters>\d+)\s+filters?\))?\s*$"
)
def parse_resolver_table(content: str, table_name: str) -> list[dict]:
"""Parse every resolver category from a dumpsys package table."""
results: list[dict] = []
in_table = False
resolver_type: str | None = None
key: str | None = None
for line in content.splitlines():
if line.startswith(f"{table_name} Resolver Table:"):
in_table = True
continue
if not in_table:
continue
if line and not line.startswith(" "):
break
section_match = re.match(r"^ {2}([^ ].*):\s*$", line)
if section_match:
resolver_type = RESOLVER_TYPES.get(section_match.group(1))
key = None
continue
if resolver_type is None:
continue
key_match = re.match(r"^ {6,}([^ ].*):\s*$", line)
if key_match:
key = key_match.group(1)
continue
if key is None:
continue
entry_match = ENTRY_RE.match(line)
if not entry_match:
continue
component = entry_match.group("component")
results.append(
{
"resolver_type": resolver_type,
"key": key,
"package_name": component.split("/", 1)[0],
"component": component,
"filter_count": int(entry_match.group("filters") or 1),
}
)
return results
+54 -73
View File
@@ -1,91 +1,72 @@
# Mobile Verification Toolkit (MVT) # Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors. # Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
FIELD_NAMES = {
"LABEL": "label",
"USER": "user",
"PID": "pid",
"TID": "tid",
"PPID": "ppid",
"VSZ": "virtual_memory_size",
"RSS": "resident_set_size",
"WCHAN": "wchan",
"ADDR": "address",
"S": "state",
"PRI": "priority",
"NI": "nice",
"RTPRIO": "realtime_priority",
"SCH": "scheduler",
"PCY": "policy",
"TIME": "cpu_time",
"CMD": "command",
"NAME": "command",
}
INTEGER_FIELDS = {
"pid",
"tid",
"ppid",
"virtual_memory_size",
"resident_set_size",
"priority",
"nice",
}
class Processes(AndroidArtifact): class Processes(AndroidArtifact):
def parse(self, entry: str) -> None: def parse(self, entry: str) -> None:
self.results = [] for line in entry.splitlines()[1:]:
lines = [line for line in entry.splitlines() if line.strip()] proc = line.split()
if not lines:
return
headers = lines[0].split()
if not all(header in FIELD_NAMES for header in headers):
return
for line in lines[1:]: # Skip empty lines
values = line.split(None, len(headers) - 1) if len(proc) == 0:
if len(values) != len(headers):
continue continue
result = {}
valid = True # Sometimes WCHAN is empty.
for header, raw in zip(headers, values): if len(proc) == 8:
key = FIELD_NAMES[header] proc = proc[:5] + [""] + proc[5:]
value: str | int = raw.strip("[]") if key == "command" else raw
if key in INTEGER_FIELDS: # Sometimes there is the security label.
try: if proc[0].startswith("u:r"):
value = int(value) label = proc[0]
except ValueError: proc = proc[1:]
valid = False else:
break label = ""
result[key] = value
if valid: # Sometimes there is no WCHAN.
self.results.append(result) if len(proc) < 9:
proc = proc[:5] + [""] + proc[5:]
self.results.append(
{
"user": proc[0],
"pid": int(proc[1]),
"ppid": int(proc[2]),
"virtual_memory_size": int(proc[3]),
"resident_set_size": int(proc[4]),
"wchan": proc[5],
"aprocress": proc[6],
"stat": proc[7],
"proc_name": proc[8].strip("[]"),
"label": label,
}
)
def check_indicators(self) -> None: def check_indicators(self) -> None:
if not self.indicators: if not self.indicators:
return return
for result in self.results: for result in self.results:
command = result.get("command", "") proc_name = result.get("proc_name", "")
if not isinstance(command, str): if not proc_name:
continue continue
process_name = command.rsplit("/", 1)[-1]
if not process_name or process_name == "gatekeeperd": # Skipping this process because of false positives.
if result["proc_name"] == "gatekeeperd":
continue continue
for checker in (
self.indicators.check_app_id, ioc_match = self.indicators.check_app_id(proc_name)
self.indicators.check_process, if ioc_match:
): self.alertstore.critical(
ioc_match = checker(process_name) ioc_match.message, "", result, matched_indicator=ioc_match.ioc
if ioc_match: )
self.alertstore.critical( continue
ioc_match.message,
"", ioc_match = self.indicators.check_process(proc_name)
result, if ioc_match:
matched_indicator=ioc_match.ioc, self.alertstore.critical(
) ioc_match.message, "", result, matched_indicator=ioc_match.ioc
break )
+17 -247
View File
@@ -3,13 +3,6 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import re
from datetime import datetime
from typing import Optional, Sequence
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.common.utils import convert_datetime_to_iso
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
ANDROID_DANGEROUS_SETTINGS = [ ANDROID_DANGEROUS_SETTINGS = [
@@ -65,246 +58,23 @@ ANDROID_DANGEROUS_SETTINGS = [
}, },
] ]
# dumpsys prints the fields of a setting record, and of a change history entry,
# always in this order and separated by a single space. After the value come
# `default:` and `defaultSystemSet:` when a default is recorded, then `tag:`;
# some vendor builds add whether the value survives a restore, either as
# `isValuePreservedInRestore:` or as a bare `notPreservedInRestore` token.
SETTING_FIELDS = (
"_id",
"name",
"pkg",
"value",
"default",
"defaultSystemSet",
"tag",
"isValuePreservedInRestore",
)
HISTORY_FIELDS = ("time", "mode", "oldValue", "newValue", "package")
NAMESPACE_PATTERN = re.compile(
r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$"
)
SECTION_END_PATTERN = re.compile(r"ending at: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
class Settings(AndroidArtifact): class Settings(AndroidArtifact):
"""Parser for the `dumpsys settings` output.
Every row of the settings provider becomes one result, keeping the fields
dumpsys prints alongside the value: the row id, the package which recorded
the setting, the default, the tag, and the change history. A setting name
can appear more than once within a namespace, so results are a list rather
than a mapping.
"""
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult:
records = []
for entry in result.get("history", []):
if not entry.get("timestamp"):
continue
records.append(
{
"timestamp": entry["timestamp"],
"module": self.__class__.__name__,
"event": "settings_change",
"data": (
f"{result.get('namespace')} setting "
f'"{result.get("name")}" changed from '
f'"{entry.get("oldValue")}" to "{entry.get("newValue")}" '
f"by {entry.get('pkg')}"
),
}
)
return records
def check_indicators(self) -> None: def check_indicators(self) -> None:
for result in self.results: for namespace, settings in self.results.items():
name = result.get("name") for key, value in settings.items():
value = result.get("value") for danger in ANDROID_DANGEROUS_SETTINGS:
for danger in ANDROID_DANGEROUS_SETTINGS: # Check if one of the dangerous settings is using an unsafe
# Check if one of the dangerous settings is using an unsafe # value (different than the one specified).
# value (different than the one specified). if danger["key"] == key and danger["safe_value"] != value:
if danger["key"] != name or danger["safe_value"] == value: self.alertstore.medium(
continue f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})',
"",
history = result.get("history") or [] {
self.alertstore.medium( "namespace": namespace,
f'Found suspicious "{result.get("namespace")}" setting ' "key": key,
f'"{name} = {value}" ({danger["description"]})', "value": value,
history[-1]["timestamp"] if history else "", "description": danger["description"],
result, },
) )
break break
def parse(self, content: str) -> None:
self.results: list[ModuleAtomicResult] = []
section_end = self._parse_section_end(content)
namespace: Optional[str] = None
user: Optional[str] = None
record_lines: list[str] = []
history_lines: list[str] = []
in_history = False
def flush() -> None:
nonlocal record_lines, history_lines, in_history
if record_lines:
self.results.append(
self._build_record(
namespace, user, record_lines, history_lines, section_end
)
)
record_lines = []
history_lines = []
in_history = False
for line in content.splitlines():
heading = NAMESPACE_PATTERN.match(line.strip())
if heading:
flush()
namespace = heading.group(1).lower()
user = heading.group(2)
continue
if line.startswith("--------- "):
# dumpsys closes every section with a duration trailer.
flush()
namespace = None
continue
if namespace is None:
continue
if not line.strip():
# dumpsys prints a blank line after every namespace block and
# after a change history, and other dumps such as the
# generation registry follow the last block, so a blank line
# closes the record being read.
flush()
continue
if line.startswith("_id:"):
flush()
record_lines = [line]
continue
if not record_lines:
continue
stripped = line.strip()
if stripped.startswith("History ("):
in_history = True
continue
if in_history:
if stripped.startswith("time:"):
history_lines.append(stripped)
elif stripped and history_lines:
# A history entry can be wrapped over several lines.
history_lines[-1] += " " + stripped
continue
# Anything else continues the value of the record being read.
record_lines.append(line)
flush()
@staticmethod
def _split_fields(text: str, keys: Sequence[str]) -> dict[str, str]:
"""Split the `key:value` fields of one record.
Values are free-form and may contain spaces and newlines, so a field
runs up to the start of the next key which is actually present. Keys
dumpsys did not print are skipped.
"""
fields: dict[str, str] = {}
key = keys[0]
if not text.startswith(f"{key}:"):
return fields
remainder = text[len(key) + 1 :]
for next_key in keys[1:]:
value, separator, rest = remainder.partition(f" {next_key}:")
if separator:
fields[key] = value
key, remainder = next_key, rest
fields[key] = remainder
return fields
@staticmethod
def _parse_section_end(content: str) -> Optional[datetime]:
"""Return the time the settings section was dumped, if reported."""
match = SECTION_END_PATTERN.search(content)
if not match:
return None
try:
return datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
@staticmethod
def _resolve_timestamp(
value: str, section_end: Optional[datetime]
) -> Optional[str]:
"""Add the missing year to a `MM-DD HH:MM:SS.mmm` history timestamp.
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
if timestamp <= section_end:
return convert_datetime_to_iso(timestamp)
return None
def _parse_history(
self, line: str, section_end: Optional[datetime]
) -> ModuleAtomicResult:
fields = self._split_fields(line, HISTORY_FIELDS)
return {
"timestamp": self._resolve_timestamp(fields.get("time", ""), section_end),
"oldValue": fields.get("oldValue"),
"newValue": fields.get("newValue"),
"pkg": fields.get("package"),
}
def _build_record(
self,
namespace: Optional[str],
user: Optional[str],
record_lines: list[str],
history_lines: list[str],
section_end: Optional[datetime],
) -> ModuleAtomicResult:
text = "\n".join(record_lines).rstrip()
# The bare `notPreservedInRestore` token has no `key:` shape and is
# printed last, so peel it off before splitting the fields.
head = text.removesuffix(" notPreservedInRestore")
record: ModuleAtomicResult = {"namespace": namespace, "user": user}
record.update(self._split_fields(head, SETTING_FIELDS))
if head != text:
record["isValuePreservedInRestore"] = "false"
record["history"] = [
self._parse_history(entry, section_end) for entry in history_lines
]
return record
+10 -33
View File
@@ -131,13 +131,6 @@ class TombstoneCrashArtifact(AndroidArtifact):
self, file_name: str, file_timestamp: datetime.datetime, data: bytes self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> None: ) -> None:
"""Parse Android tombstone crash files from a protobuf object.""" """Parse Android tombstone crash files from a protobuf object."""
self.results.append(self.parse_protobuf_record(file_name, file_timestamp, data))
def parse_protobuf_record(
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> dict:
if not data:
raise ValueError("empty protobuf tombstone")
tombstone_pb = Tombstone().parse(data) tombstone_pb = Tombstone().parse(data)
tombstone_dict = tombstone_pb.to_dict( tombstone_dict = tombstone_pb.to_dict(
casing=betterproto2.Casing.SNAKE, include_default_values=True casing=betterproto2.Casing.SNAKE, include_default_values=True
@@ -150,31 +143,20 @@ class TombstoneCrashArtifact(AndroidArtifact):
tombstone_dict["file_name"] = file_name tombstone_dict["file_name"] = file_name
tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp) tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp)
tombstone_dict["process_name"] = self._proccess_name_from_thread(tombstone_dict) tombstone_dict["process_name"] = self._proccess_name_from_thread(tombstone_dict)
if isinstance(tombstone_dict.get("selinux_label"), str):
tombstone_dict["selinux_label"] = tombstone_dict["selinux_label"].rstrip(
"\x00"
)
# Confirm the tombstone is valid, and matches the output model # Confirm the tombstone is valid, and matches the output model
tombstone = TombstoneCrashResult.model_validate(tombstone_dict) tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
return tombstone.model_dump() self.results.append(tombstone.model_dump())
def parse( def parse(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> None: ) -> None:
"""Parse text Android tombstone crash files.""" """Parse text Android tombstone crash files."""
self.results.append(self.parse_text_record(file_name, file_timestamp, content))
def parse_text_record(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> dict:
if not content:
raise ValueError("empty plaintext tombstone")
tombstone_dict = { tombstone_dict = {
"file_name": file_name, "file_name": file_name,
"file_timestamp": convert_datetime_to_iso(file_timestamp), "file_timestamp": convert_datetime_to_iso(file_timestamp),
} }
lines = content.decode("utf-8", errors="replace").splitlines() lines = content.decode("utf-8").splitlines()
for line_num, line in enumerate(lines, 1): for line_num, line in enumerate(lines, 1):
if not line.strip() or TOMBSTONE_DELIMITER in line: if not line.strip() or TOMBSTONE_DELIMITER in line:
continue continue
@@ -189,7 +171,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
# Validate the tombstone and add it to the results # Validate the tombstone and add it to the results
tombstone = TombstoneCrashResult.model_validate(tombstone_dict) tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
return tombstone.model_dump() self.results.append(tombstone.model_dump())
def _parse_tombstone_line( def _parse_tombstone_line(
self, line: str, key: str, destination_key: str, tombstone: dict self, line: str, key: str, destination_key: str, tombstone: dict
@@ -209,18 +191,11 @@ class TombstoneCrashArtifact(AndroidArtifact):
def _load_key_value_line( def _load_key_value_line(
self, line: str, key: str, destination_key: str, tombstone: dict self, line: str, key: str, destination_key: str, tombstone: dict
) -> bool: ) -> bool:
# The caller matched the key as a bare prefix, so a longer word starting line_key, value = line.split(":", 1)
# with it arrives here: `Caused by: …` inside an abort message reaches if line_key != key:
# the `Cause` key. That is a different line, not a broken file — say so raise ValueError(f"Expected key {key}, got {line_key}")
# by declining it, and let the remaining keys have their turn. Raising
# here discarded the whole tombstone, crash and stack trace included.
line_key, separator, value = line.partition(":")
if not separator or line_key != key:
return False
value_clean = value.strip() value_clean = value.strip().strip("'")
if len(value_clean) >= 2 and value_clean[0] == value_clean[-1] == "'":
value_clean = value_clean[1:-1]
if destination_key == "uid": if destination_key == "uid":
tombstone[destination_key] = int(value_clean) tombstone[destination_key] = int(value_clean)
elif destination_key == "process_uptime": elif destination_key == "process_uptime":
@@ -294,7 +269,9 @@ class TombstoneCrashArtifact(AndroidArtifact):
@staticmethod @staticmethod
def _parse_timestamp_string(timestamp: str) -> str: def _parse_timestamp_string(timestamp: str) -> str:
timestamp_parsed = parser.parse(timestamp) timestamp_parsed = parser.parse(timestamp)
return convert_datetime_to_iso(timestamp_parsed) # Preserve the source wall-clock time while returning the project-wide ISO format.
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
return convert_datetime_to_iso(local_timestamp)
@staticmethod @staticmethod
def _proccess_name_from_thread(tombstone_dict: dict) -> str: def _proccess_name_from_thread(tombstone_dict: dict) -> str:
+85 -103
View File
@@ -14,6 +14,13 @@ from mvt.common.cli_plugins import (
load_cli_commands_option, load_cli_commands_option,
register_cli_plugins, register_cli_plugins,
) )
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.help import ( from mvt.common.help import (
HELP_MSG_ANDROID_BACKUP_PASSWORD, HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_ADB_REMOVED, HELP_MSG_CHECK_ADB_REMOVED,
@@ -24,11 +31,11 @@ from mvt.common.help import (
HELP_MSG_CHECK_IOCS, HELP_MSG_CHECK_IOCS,
HELP_MSG_CHECK_INTRUSION_LOGS, HELP_MSG_CHECK_INTRUSION_LOGS,
HELP_MSG_DELAY_CHECKS, HELP_MSG_DELAY_CHECKS,
HELP_MSG_COMPLETION,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK, HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_HASHES, HELP_MSG_HASHES,
HELP_MSG_IOC, HELP_MSG_IOC,
HELP_MSG_JOBS,
HELP_MSG_LIST_MODULES, HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE, HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE, HELP_MSG_MODULE,
@@ -36,16 +43,23 @@ from mvt.common.help import (
HELP_MSG_OUTPUT, HELP_MSG_OUTPUT,
HELP_MSG_STIX2, HELP_MSG_STIX2,
HELP_MSG_VERBOSE, HELP_MSG_VERBOSE,
HELP_MSG_VERBOSE_COMMAND,
HELP_MSG_VERSION, HELP_MSG_VERSION,
HELP_MSG_VIRUS_TOTAL, HELP_MSG_VIRUS_TOTAL,
) )
from mvt.common.logo import logo
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import init_logging, set_verbose_logging from mvt.common.utils import init_logging, set_verbose_logging
# The commands import what they run only when they are invoked. This module is from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
# imported at every start of mvt-android, including by shell completion on from .cmd_check_backup import CmdAndroidCheckBackup
# every keystroke, so importing it must do no more than build the command tree: from .cmd_check_bugreport import CmdAndroidCheckBugreport
# the forensic modules, the backup parsers and the update checks stay out of it. from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from .modules.intrusion_logs import INTRUSION_LOGS_MODULES
from .modules.androidqf import ANDROIDQF_MODULES
from .modules.backup import BACKUP_MODULES
from .modules.backup.helpers import cli_load_android_backup_password
from .modules.bugreport import BUGREPORT_MODULES
init_logging() init_logging()
log = logging.getLogger("mvt") log = logging.getLogger("mvt")
@@ -63,14 +77,7 @@ def _get_disable_flags(ctx):
) )
def _get_verbose(ctx):
"""Return whether --verbose was passed to the CLI itself."""
return bool(ctx.obj and ctx.obj.get("verbose", False))
def _load_custom_modules(load_module): def _load_custom_modules(load_module):
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
try: try:
return load_custom_modules(load_module) return load_custom_modules(load_module)
except CustomModuleLoadError as exc: except CustomModuleLoadError as exc:
@@ -90,31 +97,60 @@ def _load_custom_modules(load_module):
is_flag=True, is_flag=True,
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
) )
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.pass_context @click.pass_context
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose): def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check ctx.obj["disable_indicator_check"] = disable_indicator_update_check
ctx.obj["verbose"] = verbose if ctx.invoked_subcommand != "completion":
set_verbose_logging(verbose) logo(
disable_version_check=disable_update_check,
from mvt.common.logo import logo disable_indicator_check=disable_indicator_update_check,
)
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ============================================================================== # ==============================================================================
# Command: version # Command: version
# ============================================================================== # ==============================================================================
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION) @cli.command("version", help=HELP_MSG_VERSION)
def version(): def version():
return return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-android"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ============================================================================== # ==============================================================================
# Command: check-adb (removed) # Command: check-adb (removed)
# ============================================================================== # ==============================================================================
@@ -151,20 +187,7 @@ def check_adb(ctx):
default=[], default=[],
help=HELP_MSG_LOAD_MODULE, help=HELP_MSG_LOAD_MODULE,
) )
@click.option( @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
"--timezone",
"-t",
default=None,
help=(
"IANA timezone name for the device, for example 'Europe/Paris'. "
"Bugreport file timestamps are the device's wall clock; by default the "
"zone is read from persist.sys.timezone in the bugreport itself."
),
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True)) @click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_bugreport( def check_bugreport(
@@ -174,38 +197,29 @@ def check_bugreport(
list_modules, list_modules,
module, module,
load_module, load_module,
timezone,
verbose, verbose,
jobs,
bugreport_path, bugreport_path,
): ):
from .cmd_check_bugreport import CmdAndroidCheckBugreport set_verbose_logging(verbose)
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
module_options = {}
if timezone:
module_options["device_timezone"] = timezone
# Always generate hashes as bug reports are small. # Always generate hashes as bug reports are small.
cmd = CmdAndroidCheckBugreport( cmd = CmdAndroidCheckBugreport(
target_path=bugreport_path, target_path=bugreport_path,
results_path=output, results_path=output,
ioc_files=iocs, ioc_files=iocs,
module_name=module, module_name=module,
module_options=module_options if module_options else None,
hashes=True, hashes=True,
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
return return
log.info("Checking Android bug report at path: %s", bugreport_path)
try: try:
cmd.run() cmd.run()
except BadZipFile as exc: except BadZipFile as exc:
@@ -241,10 +255,7 @@ def check_bugreport(
) )
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE) @click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD) @click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_backup( def check_backup(
@@ -256,13 +267,9 @@ def check_backup(
non_interactive, non_interactive,
backup_password, backup_password,
verbose, verbose,
jobs,
backup_path, backup_path,
): ):
from .cmd_check_backup import CmdAndroidCheckBackup set_verbose_logging(verbose)
from .modules.backup.helpers import cli_load_android_backup_password
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
# Always generate hashes as backups are generally small. # Always generate hashes as backups are generally small.
@@ -278,13 +285,14 @@ def check_backup(
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
return return
log.info("Checking Android backup at path: %s", backup_path)
cmd.run() cmd.run()
cmd.show_alerts_brief() cmd.show_alerts_brief()
cmd.show_support_message() cmd.show_support_message()
@@ -321,10 +329,7 @@ def check_backup(
) )
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE) @click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD) @click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True)) @click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_androidqf( def check_androidqf(
@@ -340,13 +345,9 @@ def check_androidqf(
non_interactive, non_interactive,
backup_password, backup_password,
verbose, verbose,
jobs,
androidqf_path, androidqf_path,
): ):
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF set_verbose_logging(verbose)
from .modules.backup.helpers import cli_load_android_backup_password
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
cmd = CmdAndroidCheckAndroidQF( cmd = CmdAndroidCheckAndroidQF(
@@ -364,13 +365,14 @@ def check_androidqf(
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
return return
log.info("Checking AndroidQF acquisition at path: %s", androidqf_path)
cmd.run() cmd.run()
cmd.show_alerts_brief() cmd.show_alerts_brief()
cmd.show_disable_adb_warning() cmd.show_disable_adb_warning()
@@ -413,10 +415,7 @@ def check_androidqf(
"time instead of UTC." "time instead of UTC."
), ),
) )
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("LOGS_PATH", type=click.Path(exists=True)) @click.argument("LOGS_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_intrusion_logs( def check_intrusion_logs(
@@ -428,12 +427,9 @@ def check_intrusion_logs(
load_module, load_module,
timezone, timezone,
verbose, verbose,
jobs,
logs_path, logs_path,
): ):
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs set_verbose_logging(verbose)
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
module_options = {} module_options = {}
@@ -449,13 +445,14 @@ def check_intrusion_logs(
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
return return
log.info("Checking intrusion logs at path: %s", logs_path)
cmd.run() cmd.run()
cmd.show_alerts_brief() cmd.show_alerts_brief()
cmd.show_support_message() cmd.show_support_message()
@@ -485,10 +482,6 @@ def check_intrusion_logs(
@click.argument("FOLDER", type=click.Path(exists=True)) @click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_iocs(ctx, iocs, list_modules, module, load_module, folder): def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from .command_modules import ANDROID_CHECK_IOCS_MODULES
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS( cmd = CmdCheckIOCS(
target_path=folder, target_path=folder,
@@ -499,7 +492,9 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
custom_modules=custom_modules, custom_modules=custom_modules,
platform="android", platform="android",
) )
cmd.modules = ANDROID_CHECK_IOCS_MODULES cmd.modules = (
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
)
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
@@ -515,25 +510,12 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
# ============================================================================== # ==============================================================================
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2) @cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
def download_indicators(): def download_indicators():
from mvt.common.updates import IndicatorsUpdates
ioc_updates = IndicatorsUpdates() ioc_updates = IndicatorsUpdates()
ioc_updates.update() ioc_updates.update()
# ============================================================================== register_cli_plugins(
# Entry point of the mvt-android console script cli,
# ============================================================================== entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
def main() -> None: environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
"""Register the external commands and run the mvt-android CLI. )
External commands are registered here rather than when this module is
imported, so that importing MVT never runs third-party code and a plugin
importing from MVT cannot re-enter a module that is still initializing.
"""
register_cli_plugins(
cli,
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
)
cli()
+3 -8
View File
@@ -53,7 +53,6 @@ class CmdAndroidCheckAndroidQF(Command):
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None, custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -69,7 +68,6 @@ class CmdAndroidCheckAndroidQF(Command):
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
self.platform = "android" self.platform = "android"
@@ -84,8 +82,6 @@ class CmdAndroidCheckAndroidQF(Command):
if not self.target_path: if not self.target_path:
raise NoAndroidQFTargetPath raise NoAndroidQFTargetPath
self.log.info("Checking AndroidQF acquisition at path: %s", self.target_path)
if os.path.isdir(self.target_path): if os.path.isdir(self.target_path):
self.__format = "dir" self.__format = "dir"
parent_path = Path(self.target_path).absolute().parent.as_posix() parent_path = Path(self.target_path).absolute().parent.as_posix()
@@ -261,7 +257,6 @@ class CmdAndroidCheckAndroidQF(Command):
hashes=self.hashes, hashes=self.hashes,
sub_command=True, sub_command=True,
custom_modules=self.custom_modules, custom_modules=self.custom_modules,
jobs=self.jobs,
) )
cmd.from_zip(bugreport) cmd.from_zip(bugreport)
cmd.run() cmd.run()
@@ -293,12 +288,13 @@ class CmdAndroidCheckAndroidQF(Command):
hashes=self.hashes, hashes=self.hashes,
sub_command=True, sub_command=True,
custom_modules=self.custom_modules, custom_modules=self.custom_modules,
jobs=self.jobs,
) )
try: try:
cmd.from_ab(backup) cmd.from_ab(backup)
except InvalidAndroidBackup as exc: except InvalidAndroidBackup as exc:
self.log.warning("Skipping backup modules: %s", exc) self.log.warning(
"Skipping backup modules as backup.ab is malformed: %s", exc
)
return False return False
cmd.run() cmd.run()
@@ -380,7 +376,6 @@ class CmdAndroidCheckAndroidQF(Command):
hashes=self.hashes, hashes=self.hashes,
sub_command=True, sub_command=True,
custom_modules=self.custom_modules, custom_modules=self.custom_modules,
jobs=self.jobs,
) )
cmd.run() cmd.run()
-7
View File
@@ -47,7 +47,6 @@ class CmdAndroidCheckBackup(Command):
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None, custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -63,7 +62,6 @@ class CmdAndroidCheckBackup(Command):
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
self.platform = "android" self.platform = "android"
@@ -89,15 +87,11 @@ class CmdAndroidCheckBackup(Command):
if header["encryption"] != "none": if header["encryption"] != "none":
password = prompt_or_load_android_backup_password(log, self.module_options) password = prompt_or_load_android_backup_password(log, self.module_options)
if not password: if not password:
if self.sub_command:
raise InvalidAndroidBackup("No backup password provided")
log.critical("No backup password provided.") log.critical("No backup password provided.")
sys.exit(1) sys.exit(1)
try: try:
tardata = parse_backup_file(ab_file_bytes, password=password) tardata = parse_backup_file(ab_file_bytes, password=password)
except InvalidBackupPassword: except InvalidBackupPassword:
if self.sub_command:
raise InvalidAndroidBackup("Invalid backup password")
log.critical("Invalid backup password") log.critical("Invalid backup password")
sys.exit(1) sys.exit(1)
except AndroidBackupParsingError as exc: except AndroidBackupParsingError as exc:
@@ -131,7 +125,6 @@ class CmdAndroidCheckBackup(Command):
assert self.target_path is not None # type: ignore[has-type] assert self.target_path is not None # type: ignore[has-type]
# Use a different local variable name to avoid any scoping issues # Use a different local variable name to avoid any scoping issues
backup_path: str = self.target_path # type: ignore[has-type] backup_path: str = self.target_path # type: ignore[has-type]
self.log.info("Checking Android backup at path: %s", backup_path)
if os.path.isfile(backup_path): if os.path.isfile(backup_path):
self.__type = "ab" self.__type = "ab"
+7 -101
View File
@@ -3,19 +3,13 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import fnmatch
import io
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from zipfile import BadZipFile, ZipFile from zipfile import ZipFile
from mvt.android.artifacts.getprop import GetProp from mvt.android.modules.bugreport.base import BugReportModule
from mvt.android.modules.bugreport.base import (
DUMPSTATE_ENTRY_POINTS,
BugReportModule,
)
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule from mvt.common.module import MVTModule
@@ -40,7 +34,6 @@ class CmdAndroidCheckBugreport(Command):
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None, custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -56,7 +49,6 @@ class CmdAndroidCheckBugreport(Command):
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
self.platform = "android" self.platform = "android"
@@ -95,100 +87,14 @@ class CmdAndroidCheckBugreport(Command):
for file_name in self.__zip.namelist(): for file_name in self.__zip.namelist():
self.__files.append(file_name) self.__files.append(file_name)
if not self._has_dumpstate(self.__files):
nested = self._nested_bugreport(bugreport_zip)
if nested:
self.__zip = nested
self.__files = list(nested.namelist())
@staticmethod
def _has_dumpstate(file_names: List[str]) -> bool:
"""Whether these members carry any of the entry points the bug report
modules read (see `BugReportModule._get_dumpstate_file`)."""
return any(
fnmatch.filter(file_names, pattern) for pattern in DUMPSTATE_ENTRY_POINTS
)
def _nested_bugreport(self, outer: ZipFile) -> Optional[ZipFile]:
"""Descend one level into an OEM wrapper archive.
MIUI / HyperOS hands out a zip of app logs, ANR traces and tcpdump
captures with the real `bugreport-<device>-<timestamp>.zip` nested
inside. Without this descent the outer archive has no entry point,
every module reports it found no files, and the command still exits 0
with an empty result.
"""
candidates = [
name for name in outer.namelist() if name.lower().endswith(".zip")
]
candidates.sort(
key=lambda name: (
"bugreport" not in name.lower() and "dumpstate" not in name.lower()
)
)
for name in candidates:
try:
inner = ZipFile(io.BytesIO(outer.read(name)))
except (BadZipFile, OSError):
continue
if self._has_dumpstate(inner.namelist()):
log.info("Found the bug report nested inside the archive: %s", name)
return inner
inner.close()
return None
def init(self) -> None: def init(self) -> None:
if self.target_path: if not self.target_path:
self.log.info("Checking Android bug report at path: %s", self.target_path)
if os.path.isfile(self.target_path):
self.from_zip(ZipFile(self.target_path))
elif os.path.isdir(self.target_path):
self.from_dir(self.target_path)
self.log.warning(
"Analysing an unpacked bugreport: file timestamps come from "
"the extraction, not from the device. Analyse the original "
"zip to keep the device's file timestamps."
)
if self.__format:
self._resolve_device_timezone()
def _resolve_device_timezone(self) -> None:
"""Name the device's timezone in module_options unless it is known already.
A bugreport's SYSTEM PROPERTIES section carries persist.sys.timezone.
Zip entry times are the device's wall clock, and modules read them in
this zone; --timezone or check-androidqf's own reading takes precedence.
"""
if self.module_options.get("device_timezone"):
self.log.info("Device timezone: %s", self.module_options["device_timezone"])
return return
probe = BugReportModule(log=self.log) if os.path.isfile(self.target_path):
self.module_init(probe) self.from_zip(ZipFile(self.target_path))
timezone = None elif os.path.isdir(self.target_path):
try: self.from_dir(self.target_path)
dumpstate = probe._get_dumpstate_file()
except Exception as exc:
self.log.warning("Could not read the bugreport's dumpstate: %s", exc)
dumpstate = None
if dumpstate:
properties = GetProp()
properties.parse(
BugReportModule.extract_command_section(
dumpstate.decode("utf-8", errors="replace"),
"------ SYSTEM PROPERTIES",
)
)
timezone = properties.get_device_timezone()
if timezone:
self.log.info("Device timezone identified from the bugreport: %s", timezone)
self.module_options["device_timezone"] = timezone
else:
self.log.warning(
"persist.sys.timezone not found in the bugreport; file timestamps "
"are the device's wall clock without a timezone. Pass --timezone "
"to name it."
)
def module_init(self, module: BugReportModule) -> None: # type: ignore[override] def module_init(self, module: BugReportModule) -> None: # type: ignore[override]
if self.__format == "zip": if self.__format == "zip":
@@ -37,7 +37,6 @@ class CmdAndroidCheckIntrusionLogs(Command):
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None, custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -53,7 +52,6 @@ class CmdAndroidCheckIntrusionLogs(Command):
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
self.platform = "android" self.platform = "android"
@@ -65,8 +63,6 @@ class CmdAndroidCheckIntrusionLogs(Command):
if not self.target_path: if not self.target_path:
raise ValueError("No target path specified") raise ValueError("No target path specified")
self.log.info("Checking intrusion logs at path: %s", self.target_path)
if not os.path.isdir(self.target_path) and not ( if not os.path.isdir(self.target_path) and not (
os.path.isfile(self.target_path) os.path.isfile(self.target_path)
and self.target_path.lower().endswith(".zip") and self.target_path.lower().endswith(".zip")
-23
View File
@@ -1,23 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Module lists an mvt-android command composes from more than one family.
Commands whose modules are one family read that family directly. check-iocs
re-checks stored results, so it has to know every module that could have
written one, and both the CLI and any other code needing that answer share
the list from here rather than each concatenating their own.
"""
from mvt.common.module import MVTModule
from .modules.androidqf import ANDROIDQF_MODULES
from .modules.backup import BACKUP_MODULES
from .modules.bugreport import BUGREPORT_MODULES
from .modules.intrusion_logs import INTRUSION_LOGS_MODULES
ANDROID_CHECK_IOCS_MODULES: list[type[MVTModule]] = (
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
)
@@ -3,8 +3,6 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .aqf_files import AQFFiles from .aqf_files import AQFFiles
from .aqf_getprop import AQFGetProp from .aqf_getprop import AQFGetProp
from .aqf_log_timestamps import AQFLogTimestamps from .aqf_log_timestamps import AQFLogTimestamps
@@ -14,7 +12,7 @@ from .aqf_settings import AQFSettings
from .mounts import Mounts from .mounts import Mounts
from .root_binaries import RootBinaries from .root_binaries import RootBinaries
ANDROIDQF_MODULES: list[type[MVTModule]] = [ ANDROIDQF_MODULES = [
AQFPackages, AQFPackages,
AQFProcesses, AQFProcesses,
AQFGetProp, AQFGetProp,
@@ -8,6 +8,8 @@ import logging
import time import time
from typing import Optional from typing import Optional
from rich.progress import track
from mvt.android.utils import ( from mvt.android.utils import (
BROWSER_INSTALLERS, BROWSER_INSTALLERS,
PLAY_STORE_INSTALLERS, PLAY_STORE_INSTALLERS,
@@ -151,9 +153,10 @@ class AQFPackages(AndroidQFModule):
if total_hashes == 0: if total_hashes == 0:
return return
self.log.info("Looking up %d package files on VirusTotal...", total_hashes) progress_desc = f"Looking up {total_hashes} package files on VirusTotal..."
for index, file_hash in enumerate(files_by_hash): for index, file_hash in enumerate(
self.log.debug("VirusTotal lookup %d/%d", index + 1, total_hashes) track(files_by_hash, description=progress_desc)
):
try: try:
results = virustotal_lookup(file_hash) results = virustotal_lookup(file_hash)
except VTNoKey as exc: except VTNoKey as exc:
@@ -3,7 +3,11 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.settings import Settings as SettingsArtifact from mvt.android.artifacts.settings import Settings as SettingsArtifact
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule from .base import AndroidQFModule
@@ -11,23 +15,43 @@ from .base import AndroidQFModule
class AQFSettings(SettingsArtifact, AndroidQFModule): class AQFSettings(SettingsArtifact, AndroidQFModule):
"""This module analyse setting files""" """This module analyse setting files"""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results: dict = results if results is not None else {}
def run(self) -> None: def run(self) -> None:
for setting_file in self._get_files_by_pattern("*/settings_*.txt"): for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
namespace = setting_file[setting_file.rfind("_") + 1 : -4] namespace = setting_file[setting_file.rfind("_") + 1 : -4]
self.results[namespace] = {}
data = self._get_file_content(setting_file) data = self._get_file_content(setting_file)
for line in data.decode("utf-8").splitlines(): for line in data.decode("utf-8").splitlines():
name, separator, value = line.strip().partition("=") line = line.strip()
if not separator: try:
key, value = line.split("=", 1)
except ValueError:
continue continue
self.results.append( try:
{ self.results[namespace][key] = value
"namespace": namespace, except IndexError:
"user": None, continue
"name": name,
"value": value,
}
)
self.log.info("Identified %d settings", len(self.results)) self.log.info(
"Identified %d settings", sum([len(val) for val in self.results.values()])
)
+7 -8
View File
@@ -81,13 +81,12 @@ class AndroidQFModule(MVTModule):
return None return None
def _get_file_content(self, file_path): def _get_file_content(self, file_path):
with self.resource_lock: if self.archive:
if self.archive: handle = self.archive.open(file_path)
handle = self.archive.open(file_path) else:
else: handle = open(os.path.join(self.parent_path, file_path), "rb")
handle = open(os.path.join(self.parent_path, file_path), "rb")
data = handle.read() data = handle.read()
handle.close() handle.close()
return data return data
+1 -3
View File
@@ -3,8 +3,6 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .sms import SMS from .sms import SMS
BACKUP_MODULES: list[type[MVTModule]] = [SMS] BACKUP_MODULES = [SMS]
+16 -17
View File
@@ -55,22 +55,21 @@ class BackupModule(MVTModule):
return fnmatch.filter(self.files, pattern) return fnmatch.filter(self.files, pattern)
def _get_file_content(self, file_path: str) -> bytes: def _get_file_content(self, file_path: str) -> bytes:
with self.resource_lock: handle = None
handle = None if self.tar:
if self.tar: try:
try: member = self.tar.getmember(file_path)
member = self.tar.getmember(file_path) handle = self.tar.extractfile(member)
handle = self.tar.extractfile(member) if not handle:
if not handle: raise ValueError(f"Could not extract file: {file_path}")
raise ValueError(f"Could not extract file: {file_path}") except KeyError:
except KeyError: raise FileNotFoundError(f"File not found in tar: {file_path}")
raise FileNotFoundError(f"File not found in tar: {file_path}") elif self.backup_path:
elif self.backup_path: handle = open(os.path.join(self.backup_path, file_path), "rb")
handle = open(os.path.join(self.backup_path, file_path), "rb") else:
else: raise ValueError("No backup path or tar file provided")
raise ValueError("No backup path or tar file provided")
data = handle.read() data = handle.read()
handle.close() handle.close()
return data return data
@@ -3,8 +3,6 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .dumpsys_accessibility import DumpsysAccessibility from .dumpsys_accessibility import DumpsysAccessibility
from .dumpsys_activities import DumpsysActivities from .dumpsys_activities import DumpsysActivities
from .dumpsys_appops import DumpsysAppops from .dumpsys_appops import DumpsysAppops
@@ -18,11 +16,8 @@ from .dumpsys_receivers import DumpsysReceivers
from .dumpsys_adb_state import DumpsysADBState from .dumpsys_adb_state import DumpsysADBState
from .fs_timestamps import BugReportTimestamps from .fs_timestamps import BugReportTimestamps
from .tombstones import Tombstones from .tombstones import Tombstones
from .mounts import Mounts
from .processes import Processes
from .settings import Settings
BUGREPORT_MODULES: list[type[MVTModule]] = [ BUGREPORT_MODULES = [
DumpsysAccessibility, DumpsysAccessibility,
DumpsysActivities, DumpsysActivities,
DumpsysAppops, DumpsysAppops,
@@ -36,7 +31,4 @@ BUGREPORT_MODULES: list[type[MVTModule]] = [
DumpsysADBState, DumpsysADBState,
BugReportTimestamps, BugReportTimestamps,
Tombstones, Tombstones,
Processes,
Settings,
Mounts,
] ]
+33 -228
View File
@@ -4,45 +4,14 @@
# https://github.com/mvt-project/mvt/blob/main/LICENSE # https://github.com/mvt-project/mvt/blob/main/LICENSE
import datetime import datetime
import fnmatch import fnmatch
import io
import logging import logging
import os import os
import re
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from zipfile import ZipFile from zipfile import ZipFile
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from mvt.android.artifacts.artifact import AndroidArtifact
from mvt.common.module import ModuleResults, MVTModule from mvt.common.module import ModuleResults, MVTModule
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
# section finishes and not necessarily between two sections.
SECTION_DURATION = re.compile(r"^-{3,}\s*[0-9.]+s was the duration of", re.IGNORECASE)
# The members a bug report archive can be entered through, in the order
# _get_dumpstate_file() tries them. An archive carrying none of them is not a
# bug report at this level (see CmdAndroidCheckBugreport._has_dumpstate).
DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
_DUMPSTATE_BYTES_CACHE_KEY = ("android_bugreport", "dumpstate", "bytes")
_DUMPSTATE_SECTIONS_CACHE_KEY = ("android_bugreport", "dumpstate", "sections")
_SYSTEM_PROPERTIES_SECTION = b"------ SYSTEM PROPERTIES"
_DUMPSYS_SEPARATORS = {
b"DUMP OF SERVICE accessibility:",
b"DUMP OF SERVICE adb:",
b"DUMP OF SERVICE appops:",
b"DUMP OF SERVICE batterystats:",
b"DUMP OF SERVICE dbinfo:",
b"DUMP OF SERVICE package:",
b"DUMP OF SERVICE platform_compat:",
}
_DUMPSTATE_DELIMITER = (
b"------------------------------------------------------------------------------"
)
_GETPROP_LINE_RE = re.compile(rb"\[(.+?)\]: \[(.+?)\]")
class BugReportModule(MVTModule): class BugReportModule(MVTModule):
"""This class provides a base for all Android Bug Report modules.""" """This class provides a base for all Android Bug Report modules."""
@@ -97,210 +66,46 @@ class BugReportModule(MVTModule):
return [] return []
def _get_file_content(self, file_path: str) -> bytes: def _get_file_content(self, file_path: str) -> bytes:
with self.resource_lock: if self.zip_archive:
if self.zip_archive: handle = self.zip_archive.open(file_path)
handle = self.zip_archive.open(file_path) else:
else: if not self.extract_path:
if not self.extract_path: raise ValueError("extract_path is not set")
raise ValueError("extract_path is not set") joined = os.path.join(self.extract_path, file_path)
joined = os.path.join(self.extract_path, file_path) if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()):
if ( raise ValueError("unsafe file_path")
not Path(joined) handle = open(joined, "rb")
.resolve()
.is_relative_to(Path(self.extract_path).resolve())
):
raise ValueError("unsafe file_path")
handle = open(joined, "rb")
data = handle.read() data = handle.read()
handle.close() handle.close()
return data return data
def _get_dumpstate_file(self) -> Optional[bytes]: def _get_dumpstate_file(self) -> Optional[bytes]:
with self.resource_lock: main = self._get_files_by_pattern("main_entry.txt")
if _DUMPSTATE_BYTES_CACHE_KEY in self.resource_cache: if main:
return self.resource_cache[_DUMPSTATE_BYTES_CACHE_KEY] main_content = self._get_file_content(main[0])
try:
return self._get_file_content(main_content.decode().strip())
except KeyError:
return None
content = None dumpstate_logs = self._get_files_by_pattern("dumpState_*.log")
main_entry, dumpstate_log, dumpsys_txt = DUMPSTATE_ENTRY_POINTS if dumpstate_logs:
main = self._get_files_by_pattern(main_entry) return self._get_file_content(dumpstate_logs[0])
if main:
main_content = self._get_file_content(main[0])
try:
content = self._get_file_content(main_content.decode().strip())
except KeyError:
pass
else:
dumpstate_logs = self._get_files_by_pattern(dumpstate_log)
if dumpstate_logs:
content = self._get_file_content(dumpstate_logs[0])
else:
dumpsys_files = self._get_files_by_pattern(dumpsys_txt)
if dumpsys_files:
content = self._get_file_content(dumpsys_files[0])
self.resource_cache[_DUMPSTATE_BYTES_CACHE_KEY] = content dumpsys_files = self._get_files_by_pattern("*/dumpsys.txt")
return content if dumpsys_files:
return self._get_file_content(dumpsys_files[0])
def _get_dumpstate_sections(self) -> dict[bytes, bytes]: return None
with self.resource_lock:
if _DUMPSTATE_SECTIONS_CACHE_KEY in self.resource_cache:
return self.resource_cache[_DUMPSTATE_SECTIONS_CACHE_KEY]
section_lines: dict[bytes, list[bytes]] = {
separator: [] for separator in _DUMPSYS_SEPARATORS
}
section_lines[_SYSTEM_PROPERTIES_SECTION] = []
current_section = None
completed_sections = set()
in_system_properties = False
content = self._get_dumpstate_file()
if content:
for raw_line in io.BytesIO(content):
line = raw_line.rstrip(b"\r\n")
stripped = line.strip()
if stripped.startswith(_SYSTEM_PROPERTIES_SECTION):
in_system_properties = True
elif in_system_properties:
if stripped == b"------":
in_system_properties = False
elif _GETPROP_LINE_RE.search(line):
section_lines[_SYSTEM_PROPERTIES_SECTION].append(line)
if stripped in _DUMPSYS_SEPARATORS:
if current_section is not None:
completed_sections.add(current_section)
current_section = (
None if stripped in completed_sections else stripped
)
continue
if stripped.startswith(_DUMPSTATE_DELIMITER):
if current_section is not None:
completed_sections.add(current_section)
current_section = None
continue
if current_section is not None:
section_lines[current_section].append(line)
sections = {
separator: b"\n".join(lines)
for separator, lines in section_lines.items()
}
self.resource_cache[_DUMPSTATE_SECTIONS_CACHE_KEY] = sections
return sections
def _get_dumpsys_section_bytes(self, separator: bytes) -> Optional[bytes]:
sections = self._get_dumpstate_sections()
if separator in sections:
return sections[separator]
content = self._get_dumpstate_file()
if not content:
return None
return AndroidArtifact.extract_dumpsys_section(content, separator, binary=True)
def _get_dumpsys_section(
self, separator: str, errors: str = "replace"
) -> Optional[str]:
cache_key = (
"android_bugreport",
"dumpstate",
"section",
separator,
errors,
)
with self.resource_lock:
if cache_key in self.resource_cache:
return self.resource_cache[cache_key]
section_bytes = self._get_dumpsys_section_bytes(separator.encode("utf-8"))
section = (
section_bytes.decode("utf-8", errors=errors)
if section_bytes is not None
else None
)
self.resource_cache[cache_key] = section
return section
def _get_system_properties_text(self, errors: str = "ignore") -> Optional[str]:
cache_key = (
"android_bugreport",
"dumpstate",
"system_properties",
errors,
)
with self.resource_lock:
if cache_key in self.resource_cache:
return self.resource_cache[cache_key]
sections = self._get_dumpstate_sections()
section_bytes = sections.get(_SYSTEM_PROPERTIES_SECTION)
section = (
section_bytes.decode("utf-8", errors=errors)
if section_bytes is not None
else None
)
self.resource_cache[cache_key] = section
return section
@staticmethod
def extract_command_section(content: str, heading: str) -> str:
"""Return a bugreport command section without consuming the next one.
Bugreport separators include timing text, so looking for a line equal to
``------`` is not sufficient and can accidentally feed the remainder of
dumpstate to a parser.
"""
lines: list[str] = []
in_section = False
for line in content.splitlines():
stripped = line.strip()
if not in_section:
if stripped.startswith(heading):
in_section = True
continue
if stripped.startswith("------"):
# dumpstate prints a section's timing line when that section
# finishes, which can land in the middle of the one being
# written. Treating it as a boundary truncates the section at
# an arbitrary point, silently.
if SECTION_DURATION.match(stripped):
continue
break
lines.append(line)
return "\n".join(lines)
def _device_timezone(self) -> Optional[datetime.tzinfo]:
"""The device's timezone named in module_options, or None when unknown."""
name = self.module_options.get("device_timezone")
if not name:
return None
try:
return ZoneInfo(name)
except ZoneInfoNotFoundError:
self.log.warning("Unknown device timezone %s", name)
return None
def _get_file_modification_time(self, file_path: str) -> datetime.datetime: def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
"""When the file was last modified.
A zip entry carries the device's wall clock, so it is returned in the
device's timezone when the bugreport names one and naive otherwise.
An unpacked bugreport's mtime is whatever the extraction left, an
instant returned in UTC.
"""
if self.zip_archive: if self.zip_archive:
with self.resource_lock: file_timetuple = self.zip_archive.getinfo(file_path).date_time
file_timetuple = self.zip_archive.getinfo(file_path).date_time return datetime.datetime(*file_timetuple)
return datetime.datetime(*file_timetuple, tzinfo=self._device_timezone()) else:
if not self.extract_path: if not self.extract_path:
raise ValueError("extract_path is not set") raise ValueError("extract_path is not set")
file_stat = os.stat(os.path.join(self.extract_path, file_path)) file_stat = os.stat(os.path.join(self.extract_path, file_path))
return datetime.datetime.fromtimestamp( return datetime.datetime.fromtimestamp(file_stat.st_mtime)
file_stat.st_mtime, tz=datetime.timezone.utc
)
@@ -34,34 +34,25 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
) )
def run(self) -> None: def run(self) -> None:
content = self._get_dumpsys_section( full_dumpsys = self._get_dumpstate_file()
"DUMP OF SERVICE accessibility:", errors="ignore" if not full_dumpsys:
)
if content is None:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
content = self.extract_dumpsys_section(
full_dumpsys.decode("utf-8", errors="ignore"),
"DUMP OF SERVICE accessibility:",
)
self.parse(content) self.parse(content)
listed = unnamed = 0
for result in self.results: for result in self.results:
if result.get("component"): self.log.info(
listed += 1 'Found installed accessibility service "%s"', result.get("service")
self.log.info( )
'Found accessibility service "%s"', result.get("component")
)
continue
# The operator gets this per user as a LOW alert from
# check_indicators(); here it only has to survive into the summary,
# so that a stated count never reads as "a total of 0".
unnamed += result.get("unnamed_service_count") or 0
self.log.info( self.log.info(
"Identified a total of %d accessibility services (%d more stated by " "Identified a total of %d accessibility services", len(self.results)
"the dump without a component name)",
listed,
unnamed,
) )
@@ -38,14 +38,19 @@ class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule):
self.results = results if results else [] self.results = results if results else []
def run(self) -> None: def run(self) -> None:
section = self._get_dumpsys_section("DUMP OF SERVICE package:", errors="ignore") content = self._get_dumpstate_file()
if section is None: if not content:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
# Extract package section
section = self.extract_dumpsys_section(
content.decode("utf-8", errors="ignore"), "DUMP OF SERVICE package:"
)
# Parse # Parse
self.parse(section) self.parse(section)
@@ -38,14 +38,19 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
) )
def run(self) -> None: def run(self) -> None:
content = self._get_dumpsys_section_bytes(b"DUMP OF SERVICE adb:") full_dumpsys = self._get_dumpstate_file()
if content is None: if not full_dumpsys:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
content = self.extract_dumpsys_section(
full_dumpsys,
b"DUMP OF SERVICE adb:",
binary=True,
)
self.parse(content) self.parse(content)
if self.results: if self.results:
self.log.info( self.log.info(
@@ -34,14 +34,17 @@ class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule):
) )
def run(self) -> None: def run(self) -> None:
section = self._get_dumpsys_section("DUMP OF SERVICE appops:") content = self._get_dumpstate_file()
if section is None: if not content:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE appops:"
)
self.parse(section) self.parse(section)
self.log.info( self.log.info(
@@ -34,14 +34,17 @@ class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule):
) )
def run(self) -> None: def run(self) -> None:
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE batterystats:") content = self._get_dumpstate_file()
if dumpsys_section is None: if not content:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
dumpsys_section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
)
self.parse(dumpsys_section) self.parse(dumpsys_section)
self.log.info("Extracted a total of %d battery daily stats", len(self.results)) self.log.info("Extracted a total of %d battery daily stats", len(self.results))
@@ -34,14 +34,17 @@ class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule):
) )
def run(self) -> None: def run(self) -> None:
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE batterystats:") content = self._get_dumpstate_file()
if dumpsys_section is None: if not content:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
dumpsys_section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
)
self.parse(dumpsys_section) self.parse(dumpsys_section)
self.log.info( self.log.info(
@@ -36,14 +36,18 @@ class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule):
) )
def run(self) -> None: def run(self) -> None:
section = self._get_dumpsys_section("DUMP OF SERVICE dbinfo:", errors="ignore") data = self._get_dumpstate_file()
if section is None: if not data:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="ignore"), "DUMP OF SERVICE dbinfo:"
)
self.parse(section) self.parse(section)
self.log.info( self.log.info(
"Extracted a total of %d database connection pool records", "Extracted a total of %d database connection pool records",
@@ -36,13 +36,29 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
self.results = [] if not results else results self.results = [] if not results else results
def run(self) -> None: def run(self) -> None:
content = self._get_system_properties_text() content = self._get_dumpstate_file()
if content is None: if not content:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
self.parse(content) lines = []
in_getprop = False
for line in content.decode(errors="ignore").splitlines():
if line.strip().startswith("------ SYSTEM PROPERTIES"):
in_getprop = True
continue
if not in_getprop:
continue
if line.strip() == "------":
break
lines.append(line)
self.parse("\n".join(lines))
self.log.info("Extracted %d Android system properties", len(self.results)) self.log.info("Extracted %d Android system properties", len(self.results))
@@ -35,22 +35,22 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
) )
def run(self) -> None: def run(self) -> None:
content = self._get_dumpsys_section("DUMP OF SERVICE package:") data = self._get_dumpstate_file()
if content is None: if not data:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
content = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
)
self.parse(content) self.parse(content)
for result in self.results: for result in self.results:
dangerous_permissions_count = 0 dangerous_permissions_count = 0
permissions = list(result["permissions"]) for perm in result["permissions"]:
for user in result.get("users", []):
permissions.extend(user.get("permissions", []))
for perm in permissions:
if perm["name"] in DANGEROUS_PERMISSIONS: if perm["name"] in DANGEROUS_PERMISSIONS:
dangerous_permissions_count += 1 dangerous_permissions_count += 1
@@ -13,7 +13,7 @@ from mvt.common.module_types import ModuleResults
class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule): class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
"""This module extracts raw per-package compatibility overrides.""" """This module extracts details on uninstalled apps."""
def __init__( def __init__(
self, self,
@@ -34,14 +34,18 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
) )
def run(self) -> None: def run(self) -> None:
content = self._get_dumpsys_section("DUMP OF SERVICE platform_compat:") data = self._get_dumpstate_file()
if content is None: if not data:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
decoded_data = data.decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(
decoded_data, "DUMP OF SERVICE platform_compat:"
)
self.parse(content) self.parse(content)
self.log.info("Found %d package compatibility overrides", len(self.results)) self.log.info("Found %d uninstalled apps", len(self.results))
@@ -33,16 +33,20 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
results=results, results=results,
) )
self.results = results if results else [] self.results = results if results else {}
def run(self) -> None: def run(self) -> None:
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE package:") content = self._get_dumpstate_file()
if dumpsys_section is None: if not content:
self.log.error( self.log.error(
"Unable to find dumpstate file. " "Unable to find dumpstate file. "
"Did you provide a valid bug report archive?" "Did you provide a valid bug report archive?"
) )
return return
dumpsys_section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
)
self.parse(dumpsys_section) self.parse(dumpsys_section)
self.log.info("Extracted %d package receivers", len(self.results)) self.log.info("Extracted receivers for %d intents", len(self.results))
@@ -40,29 +40,13 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
self.results = [] self.results = []
for file in filesystem_files: for file in filesystem_files:
# A zip entry keeps the device's wall clock, read in the device's # Only the modification time is available in the zip file metadata.
# timezone when the command found one (see CmdAndroidCheckBugreport); # The timezone is the local timezone of the machine the phone.
# an unpacked bugreport's mtime is read as a UTC instant.
modification_time = self._get_file_modification_time(file) modification_time = self._get_file_modification_time(file)
self.results.append( self.results.append(
{ {
"path": file, "path": file,
"modified_time": convert_datetime_to_iso( "modified_time": convert_datetime_to_iso(modification_time),
modification_time.replace(tzinfo=None)
),
"modified_time_utc": (
convert_datetime_to_iso(modification_time)
if modification_time.tzinfo
else None
),
"timezone": (
self.module_options.get("device_timezone")
if self.zip_archive
else "UTC"
),
"timestamp_source": (
"zip_metadata" if self.zip_archive else "filesystem_metadata"
),
} }
) )
@@ -1,41 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
import re
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
from .base import BugReportModule
class Mounts(MountsArtifact, BugReportModule):
"""Extract and deduplicate process mount namespaces from mountinfo."""
def run(self) -> None:
mount_files = self._get_files_by_pattern("FS/proc/*/mountinfo")
if not mount_files:
mount_files = self._get_files_by_pattern("*/proc/*/mountinfo")
unique: dict[tuple, dict] = {}
for file_path in mount_files:
pid_match = re.search(r"/proc/(\d+)/mountinfo$", file_path)
if not pid_match:
continue
entries = self.parse_mountinfo(
self._get_file_content(file_path).decode("utf-8", errors="replace"),
int(pid_match.group(1)),
)
for entry in entries:
identity = (
entry["major_minor"],
entry["root"],
entry["mount_point"],
entry["device"],
entry["filesystem_type"],
entry["mount_options"],
)
if identity in unique:
unique[identity]["process_ids"].extend(entry["process_ids"])
else:
unique[identity] = entry
self.results = list(unique.values())
self.log.info("Extracted %d unique mount records", len(self.results))
@@ -1,22 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
from mvt.android.artifacts.processes import Processes as ProcessesArtifact
from .base import BugReportModule
class Processes(ProcessesArtifact, BugReportModule):
"""Extract the process and thread table from dumpstate."""
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
self.log.error("Unable to find dumpstate file")
return
section = self.extract_command_section(
data.decode("utf-8", errors="replace"),
"------ PROCESSES AND THREADS",
)
self.parse(section)
self.log.info("Identified %d running process threads", len(self.results))
@@ -1,21 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
from mvt.android.artifacts.settings import Settings as SettingsArtifact
from .base import BugReportModule
class Settings(SettingsArtifact, BugReportModule):
"""Extract all SettingsProvider namespaces and users."""
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
self.log.error("Unable to find dumpstate file")
return
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:"
)
self.parse(section)
self.log.info("Identified %d Android settings", len(self.results))
+13 -68
View File
@@ -43,76 +43,21 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
) )
return return
grouped: dict[str, dict[str, str]] = {} for tombstone_file in sorted(tombstone_files):
for file_path in tombstone_files: tombstone_filename = tombstone_file.split("/")[-1]
file_name = file_path.rsplit("/", 1)[-1] modification_time = self._get_file_modification_time(tombstone_file)
source = "protobuf" if file_name.endswith(".pb") else "text" tombstone_data = self._get_file_content(tombstone_file)
crash_id = file_name.removesuffix(".pb")
grouped.setdefault(crash_id, {})[source] = file_path
for crash_id, paths in sorted(grouped.items()): try:
parsed_sources: dict[str, dict] = {} if tombstone_file.endswith(".pb"):
source_records: dict[str, dict] = {} self.parse_protobuf(
for source in ("text", "protobuf"): tombstone_filename, modification_time, tombstone_data
file_path = paths.get(source)
if file_path is None:
continue
file_name = file_path.rsplit("/", 1)[-1]
file_timestamp = self._get_file_modification_time(file_path)
source_info = {
"file_name": file_name,
"file_timestamp": file_timestamp.isoformat(),
"parsed": False,
"error": None,
"record": None,
}
try:
data = self._get_file_content(file_path)
if source == "protobuf":
record = self.parse_protobuf_record(
file_name, file_timestamp, data
)
else:
record = self.parse_text_record(file_name, file_timestamp, data)
source_info["parsed"] = True
source_info["record"] = record
source_records[source] = record
except Exception as exc:
source_info["error"] = str(exc)
self.log.error(
"Error parsing tombstone file %s: %s", file_path, exc
) )
parsed_sources[source] = source_info else:
self.parse(tombstone_filename, modification_time, tombstone_data)
if not source_records: except ValueError as e:
continue # Catch any exceptions raised during parsing or validation.
preferred = source_records.get("protobuf") or source_records["text"] self.log.error(f"Error parsing tombstone file {tombstone_file}: {e}")
canonical = dict(preferred)
text_record = source_records.get("text")
if text_record:
for key, value in text_record.items():
if canonical.get(key) in (None, "", [], {}):
canonical[key] = value
differences = {}
protobuf_record = source_records.get("protobuf")
if text_record and protobuf_record:
for key in text_record.keys() & protobuf_record.keys():
if key in ("file_name", "file_timestamp"):
continue
if text_record[key] != protobuf_record[key]:
differences[key] = {
"text": text_record[key],
"protobuf": protobuf_record[key],
}
canonical.update(
{
"crash_id": crash_id,
"sources": parsed_sources,
"differences": differences,
}
)
self.results.append(canonical)
self.log.info( self.log.info(
"Extracted a total of %d tombstone files", "Extracted a total of %d tombstone files",
@@ -3,13 +3,11 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .connect_event import ConnectEvent from .connect_event import ConnectEvent
from .dns_event import DnsEvent from .dns_event import DnsEvent
from .security_event import SecurityEvent from .security_event import SecurityEvent
INTRUSION_LOGS_MODULES: list[type[MVTModule]] = [ INTRUSION_LOGS_MODULES = [
DnsEvent, DnsEvent,
ConnectEvent, ConnectEvent,
SecurityEvent, SecurityEvent,
-110
View File
@@ -1,110 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import click
from mvt.common.cli_plugins import (
MVT_CUSTOM_COMMANDS_ENV,
NEUTRAL_CLI_PLUGIN_GROUP,
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.cmd_plugins import plugins
from mvt.common.completion import completion
from mvt.common.help import (
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_STIX2,
HELP_MSG_VERBOSE,
HELP_MSG_VERSION,
)
from mvt.common.logo import logo
from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import init_logging, set_verbose_logging
init_logging()
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
# ==============================================================================
# Main
# ==============================================================================
@click.group(invoke_without_command=True)
@load_cli_commands_option
@click.option(
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
)
@click.option(
"--disable-indicator-update-check",
is_flag=True,
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.pass_context
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
"""Mobile Verification Toolkit.
The 'mvt-ios' and 'mvt-android' CLI commands are used to perform
forensic analysis on IOS and Android devices.
"""
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
ctx.obj["verbose"] = verbose
set_verbose_logging(verbose)
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ==============================================================================
# Command: download-iocs
# ==============================================================================
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
def download_iocs():
ioc_updates = IndicatorsUpdates()
ioc_updates.update()
# ==============================================================================
# Command: completion
# ==============================================================================
cli.add_command(completion)
# ==============================================================================
# Command: version
# ==============================================================================
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
def version():
return
# The plugins command is registered as a built-in command, before any external
# command, so that an installed package can never replace it.
cli.add_command(plugins)
# ==============================================================================
# Entry point of the mvt console script
# ==============================================================================
def main() -> None:
"""Register the external commands and run the mvt CLI.
External commands are registered here rather than when this module is
imported, so that importing MVT never runs third-party code and a plugin
importing from MVT cannot re-enter a module that is still initializing.
"""
register_cli_plugins(
cli,
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
)
cli()
+1 -21
View File
@@ -15,20 +15,10 @@ from typing import Iterable
import click import click
# This module is imported by every CLI at start-up, and by shell completion on
# every keystroke, so it must stay cheap: nothing here may import the module
# machinery (mvt.common.module_loader and what it pulls in).
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins" IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins" ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
# Commands in this group are registered on the platform-neutral mvt command only.
NEUTRAL_CLI_PLUGIN_GROUP = "mvt.cli_plugins"
MVT_CUSTOM_COMMANDS_ENV = "MVT_CUSTOM_COMMANDS"
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS" MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS" MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
# Prefix of the import name given to a command file loaded from a path. Shared
# with module_loader, which recognises such files when naming their loggers.
CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_"
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -64,7 +54,7 @@ class BrokenPluginCommand(click.Command):
def _module_name_for_path(path: Path) -> str: def _module_name_for_path(path: Path) -> str:
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16] digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
return f"{CUSTOM_COMMAND_MODULE_PREFIX}{path.stem}_{digest}" return f"_mvt_custom_command_{path.stem}_{digest}"
def _iter_command_files(path: Path) -> Iterable[Path]: def _iter_command_files(path: Path) -> Iterable[Path]:
@@ -265,16 +255,6 @@ def register_cli_plugins(
entry_point_group: str, entry_point_group: str,
environment_variable: str, environment_variable: str,
) -> None: ) -> None:
"""Register the external commands of one CLI on its group.
Each CLI has one entry-point group and one environment variable of its
own, so a command package chooses the CLIs its commands are added to.
:param group: CLI group to register the external commands on.
:param entry_point_group: Entry-point group of the CLI.
:param environment_variable: Name of the environment variable holding a
path to load commands from.
"""
environment_path = os.environ.get(environment_variable) environment_path = os.environ.get(environment_variable)
if environment_path: if environment_path:
register_cli_commands_from_path(group, environment_path) register_cli_commands_from_path(group, environment_path)
+2 -6
View File
@@ -56,10 +56,6 @@ class CmdCheckIOCS(Command):
if entry not in all_modules: if entry not in all_modules:
all_modules.append(entry) all_modules.append(entry)
# Read the indicators once, so that a missing indicators file is
# reported even when no stored result matches a module.
iocs = self.iocs
log.info("Checking stored results against provided indicators...") log.info("Checking stored results against provided indicators...")
total_detections = 0 total_detections = 0
@@ -87,8 +83,8 @@ class CmdCheckIOCS(Command):
log.warning("No result from this module, skipping it") log.warning("No result from this module, skipping it")
continue continue
if iocs.total_ioc_count > 0: if self.iocs.total_ioc_count > 0:
m.indicators = iocs m.indicators = self.iocs
m.indicators.log = m.log m.indicators.log = m.log
try: try:
-205
View File
@@ -1,205 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import importlib.metadata
import logging
from typing import Optional
import click
from rich.console import Console
from rich.table import Table
from .cli_plugins import (
ANDROID_CLI_PLUGIN_GROUP,
IOS_CLI_PLUGIN_GROUP,
NEUTRAL_CLI_PLUGIN_GROUP,
)
from .config import settings
from .help import (
HELP_MSG_PLUGINS,
HELP_MSG_PLUGINS_CHECK_UPDATES,
HELP_MSG_PLUGINS_LIST,
)
from .module import MVTModule
from .module_loader import MODULES_ENTRY_POINT_GROUP, distribution_direct_url
from .updates import (
SHORT_COMMIT_LENGTH,
PluginUpdates,
installed_plugin_distributions,
)
log = logging.getLogger(__name__)
CLI_PLUGIN_GROUPS = (
IOS_CLI_PLUGIN_GROUP,
ANDROID_CLI_PLUGIN_GROUP,
NEUTRAL_CLI_PLUGIN_GROUP,
)
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def _entry_points(group: str) -> list[importlib.metadata.EntryPoint]:
try:
return list(importlib.metadata.entry_points(group=group))
except Exception as exc:
log.warning("Unable to discover the entry points in group %s: %s", group, exc)
return []
def _entry_point_distribution(
entry_point: importlib.metadata.EntryPoint,
) -> Optional[str]:
dist = getattr(entry_point, "dist", None)
if dist is None:
return None
try:
return dist.name
except Exception:
return None
def _distribution_version(dist: importlib.metadata.Distribution) -> str:
try:
return dist.version or "unknown"
except Exception:
return "unknown"
def _distribution_origin(dist: importlib.metadata.Distribution) -> str:
"""Describe where a plugin package was installed from."""
direct_url = distribution_direct_url(dist)
if direct_url is None:
return "pypi"
vcs_info = direct_url.get("vcs_info")
if isinstance(vcs_info, dict):
commit = vcs_info.get("commit_id") or ""
if commit:
return f"git+{commit[:SHORT_COMMIT_LENGTH]}"
return "git"
return "local"
def _contributed_modules(
entry_points: list[importlib.metadata.EntryPoint], distribution: str
) -> str:
"""Count the forensic modules a plugin package contributes.
Entry points are resolved the way MVT resolves them when it loads
modules, but a broken entry point is reported instead of raising: listing
the installed plugins must work even when one of them is faulty.
"""
count = 0
broken = False
for entry_point in entry_points:
if _entry_point_distribution(entry_point) != distribution:
continue
try:
loaded = entry_point.load()
if callable(loaded) and not isinstance(loaded, type):
loaded = loaded()
count += sum(
1
for module in loaded
if isinstance(module, type) and issubclass(module, MVTModule)
)
except (Exception, SystemExit) as exc:
log.debug(
"Unable to load the modules of entry point %s (%s): %s",
entry_point.name,
entry_point.value,
exc,
)
broken = True
if broken:
return f"{count} (error)" if count else "error"
return str(count)
def _contributed_commands(
entry_points: list[importlib.metadata.EntryPoint], distribution: str
) -> str:
names = {
entry_point.name
for entry_point in entry_points
if _entry_point_distribution(entry_point) == distribution
}
return ", ".join(sorted(names)) if names else "-"
@click.group("plugins", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS)
def plugins() -> None:
pass
@plugins.command("list", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS_LIST)
def list_plugins() -> None:
distributions = installed_plugin_distributions()
if not distributions:
click.echo("No MVT plugins are installed.")
return
module_entry_points = _entry_points(MODULES_ENTRY_POINT_GROUP)
command_entry_points = []
for group in CLI_PLUGIN_GROUPS:
command_entry_points.extend(_entry_points(group))
table = Table(title="Installed MVT plugins")
table.add_column("Name", style="bold")
table.add_column("Version")
table.add_column("Origin")
table.add_column("Modules", justify="right")
table.add_column("Commands")
for dist in distributions:
name = dist.name
table.add_row(
name,
_distribution_version(dist),
_distribution_origin(dist),
_contributed_modules(module_entry_points, name),
_contributed_commands(command_entry_points, name),
)
Console().print(table)
@plugins.command(
"check-updates",
context_settings=CONTEXT_SETTINGS,
help=HELP_MSG_PLUGINS_CHECK_UPDATES,
short_help="Check the installed plugins for updates",
)
def check_plugin_updates() -> None:
if not settings.NETWORK_ACCESS_ALLOWED:
click.echo(
"Network access is disabled, cannot check for plugin updates. "
"Enable NETWORK_ACCESS_ALLOWED in the MVT configuration to check."
)
return
if not installed_plugin_distributions():
click.echo("No MVT plugins are installed.")
return
findings = PluginUpdates().check()
if not findings:
click.echo("All plugins are up to date.")
return
click.echo("Plugin updates available:")
for finding in findings:
click.echo(f" {finding['name']} {finding['installed']} → {finding['latest']}")
click.echo(f" Upgrade with: {finding['upgrade_command']}")
click.echo(
"\nMVT does not install plugin updates. Run the command above when you "
"decide to upgrade."
)
+60 -587
View File
@@ -6,12 +6,8 @@
import json import json
import logging import logging
import os import os
import queue
import sys import sys
import threading
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from functools import cached_property
from heapq import heappop, heappush from heapq import heappop, heappush
from typing import Any, Optional from typing import Any, Optional
@@ -22,7 +18,6 @@ from rich.text import Text
from .alerts import AlertLevel, AlertStore from .alerts import AlertLevel, AlertStore
from .config import settings from .config import settings
from .indicators import Indicators from .indicators import Indicators
from .log import MVTLogHandler, finish_module_log_buffer, start_module_log_buffer
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
from .module_loader import ( from .module_loader import (
ModuleOrigin, ModuleOrigin,
@@ -56,7 +51,6 @@ class Command:
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None, custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None: ) -> None:
self.name = "" self.name = ""
self.platform = "" self.platform = ""
@@ -72,21 +66,12 @@ class Command:
self.sub_command = sub_command self.sub_command = sub_command
self.disable_version_check = disable_version_check self.disable_version_check = disable_version_check
self.disable_indicator_check = disable_indicator_check self.disable_indicator_check = disable_indicator_check
if jobs < 1:
raise ValueError("jobs must be at least 1")
self.jobs = jobs
self._resource_lock = threading.RLock()
self._resource_cache: dict[object, Any] = {}
# This dictionary can contain options that will be passed down from # This dictionary can contain options that will be passed down from
# the Command to all modules. This can for example be used to pass # the Command to all modules. This can for example be used to pass
# down a password to decrypt a backup or flags which are need by some modules. # down a password to decrypt a backup or flags which are need by some modules.
self.module_options = module_options if module_options else {} self.module_options = module_options if module_options else {}
# This dictionary maps the modules which were replaced by a module
# declaring `replaces` to the module which took their place.
self.module_replacements: dict[type[MVTModule], type[MVTModule]] = {}
# This list will contain all executed modules. # This list will contain all executed modules.
# We can use this to reference e.g. self.executed[0].results. # We can use this to reference e.g. self.executed[0].results.
self.executed: list[MVTModule] = [] self.executed: list[MVTModule] = []
@@ -95,18 +80,18 @@ class Command:
self.timeline: ModuleTimeline = [] self.timeline: ModuleTimeline = []
self.url_results: list[URLResult] = [] self.url_results: list[URLResult] = []
# Load IOCs
self._create_storage()
self._setup_logging()
if iocs is not None: if iocs is not None:
self.iocs = iocs self.iocs = iocs
else:
self.iocs = Indicators(self.log)
self.iocs.load_indicators_files(self.ioc_files)
self.alertstore = AlertStore() self.alertstore = AlertStore()
@cached_property
def iocs(self) -> Indicators:
"""Load indicators on first use. Nested commands share their parent's."""
iocs = Indicators(self.log)
iocs.load_indicators_files(self.ioc_files)
return iocs
def _create_storage(self) -> None: def _create_storage(self) -> None:
if self.results_path and not os.path.exists(self.results_path): if self.results_path and not os.path.exists(self.results_path):
try: try:
@@ -272,174 +257,7 @@ class Command:
if module not in deduplicated: if module not in deduplicated:
deduplicated.append(module) deduplicated.append(module)
available = self._apply_replacements(deduplicated) return deduplicated
self._warn_about_slug_collisions(available)
return available
def _warn_about_slug_collisions(self, modules: list[type[MVTModule]]) -> None:
"""Report modules writing their results to the same file.
Results are stored in a file named after the module slug, so two
modules sharing one silently overwrite each other. Replacements are
already resolved here, so a module deliberately taking over the slug
of the module it replaces is not reported: the module it replaced is
no longer part of the run.
"""
modules_by_slug: dict[str, type[MVTModule]] = {}
for module in modules:
slug = module.get_slug()
first = modules_by_slug.setdefault(slug, module)
if first is module:
continue
self.log.warning(
"Modules %s from %s and %s from %s both use the slug %s. If "
"both run, whichever runs last overwrites the results of the "
"other in %s.json.",
first.__name__,
get_module_origin(first).label,
module.__name__,
get_module_origin(module).label,
slug,
slug,
)
def _declared_replacements(
self, modules: list[type[MVTModule]]
) -> dict[type[MVTModule], type[MVTModule]]:
"""Return the replacements declared by the given modules."""
replacements: dict[type[MVTModule], type[MVTModule]] = {}
for module in modules:
replaced = module.replaces
if replaced is None or replaced is module:
continue
if not module.enabled:
# Replacing a module must not disable it, as a disabled
# replacement never runs in its place.
self.log.debug(
"Module %s is disabled and does not replace module %s.",
module.__name__,
replaced.__name__,
)
continue
if replaced not in modules:
# A module can support several commands while the module it
# replaces is only available in some of them.
self.log.debug(
"Module %s replaces module %s, which is not available "
"for the %s command.",
module.__name__,
replaced.__name__,
self.name,
)
continue
if replaced in replacements:
self.log.warning(
"Modules %s and %s both replace module %s. Both of them "
"will run, %s will not, and modules depending on %s will "
"use the results of %s. Replacements which share the slug "
"of %s overwrite each other's results file.",
replacements[replaced].__name__,
module.__name__,
replaced.__name__,
replaced.__name__,
replaced.__name__,
replacements[replaced].__name__,
replaced.__name__,
)
continue
replacements[replaced] = module
return replacements
def _drop_replacement_cycles(
self, replacements: dict[type[MVTModule], type[MVTModule]]
) -> None:
"""Undo the replacements between modules which replace each other."""
cyclic: set[type[MVTModule]] = set()
for replaced in replacements:
walked: list[type[MVTModule]] = []
module = replaced
while module in replacements and module not in cyclic:
if module in walked:
cyclic.update(walked[walked.index(module) :])
break
walked.append(module)
module = replacements[module]
if not cyclic:
return
self.log.warning(
"Modules %s replace each other in a cycle. None of them replaces "
"anything and all of them will run.",
", ".join(sorted(module.__name__ for module in cyclic)),
)
for module in cyclic:
replacements.pop(module, None)
def _log_replacement(
self,
replaced: type[MVTModule],
module: type[MVTModule],
replacement: type[MVTModule],
) -> None:
"""Report an applied replacement, and any problem with it."""
if not issubclass(module, replaced):
self.log.warning(
"Module %s replaces module %s but is not a subclass of it. "
"Its results might not be compatible with what modules "
"depending on %s expect.",
module.__name__,
replaced.__name__,
replaced.__name__,
)
if replaced in module.dependencies:
self.log.warning(
"Module %s depends on module %s, which it also replaces. The "
"dependency cannot be satisfied: a replacement has to produce "
"that data itself.",
module.__name__,
replaced.__name__,
)
self.log.info(
"Module %s from %s replaces module %s from %s.",
replacement.__name__,
get_module_origin(replacement).label,
replaced.__name__,
get_module_origin(replaced).label,
)
def _apply_replacements(
self, modules: list[type[MVTModule]]
) -> list[type[MVTModule]]:
"""Drop the modules superseded by a module declaring `replaces`."""
declared = self._declared_replacements(modules)
self._drop_replacement_cycles(declared)
replacements: dict[type[MVTModule], type[MVTModule]] = {}
for replaced, module in declared.items():
# Follow chains of replacements, so that a dependency on a
# replaced module always resolves to a module which is part of
# the run.
replacement = module
while replacement in declared:
replacement = declared[replacement]
# Only the replacements which are applied are reported, so that
# the record matches the modules which actually run.
self._log_replacement(replaced, module, replacement)
replacements[replaced] = replacement
self.module_replacements = replacements
return [module for module in modules if module not in replacements]
def init(self) -> None: def init(self) -> None:
raise NotImplementedError raise NotImplementedError
@@ -499,196 +317,42 @@ class Command:
console.print("") console.print("")
console.print(panel) console.print(panel)
def _module_dependencies(
self, module: type[MVTModule]
) -> list[tuple[type[MVTModule], type[MVTModule]]]:
"""Return the (declared, resolved) dependencies of a module.
A dependency on a module which was replaced is resolved to the module
which took its place. A module which replaces one of its own
dependencies is not made to depend on itself, while a module which
declares itself as a dependency is left alone and still fails the
circular dependency check.
"""
dependencies = []
for dependency in module.dependencies:
resolved = self.module_replacements.get(dependency, dependency)
if resolved is module and dependency is not module:
continue
dependencies.append((dependency, resolved))
return dependencies
@staticmethod
def _dependency_name(
declared: type[MVTModule], resolved: type[MVTModule]
) -> str:
"""Return how a dependency is named in the messages about it.
A dependency is named as the module which declared it wrote it, and,
when that module was replaced, as the module which runs in its place.
"""
if declared is resolved:
return declared.__name__
return f"{resolved.__name__} (replacing module {declared.__name__})"
def _selected_modules(
self, modules: list[type[MVTModule]]
) -> Optional[list[type[MVTModule]]]:
"""Return the modules explicitly requested, or all the enabled ones.
Returns None when a module was requested by name and no module of
that name can be run.
"""
if not self.module_name:
return [module for module in modules if module.enabled]
selected = [
module for module in modules if module.__name__ == self.module_name
]
# A module replacing another one does not have to keep its name, so
# the name of a replaced module selects its replacement.
if not selected:
for replaced, replacement in self.module_replacements.items():
if replaced.__name__ != self.module_name or replacement in selected:
continue
self.log.info(
"Module %s was replaced by module %s, which is run "
"in its place.",
replaced.__name__,
replacement.__name__,
)
selected.append(replacement)
if not selected:
self.log.warning(
"No module named %s is available for the %s command. "
"No modules will be run.",
self.module_name,
self.name,
)
return None
return selected
def _skipped_modules(
self,
required: list[type[MVTModule]],
module_indexes: dict[type[MVTModule], int],
) -> dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]]:
"""Return the modules to drop because a dependency is unavailable.
A module declaring a dependency this command cannot provide is unable
to run, and so is every module depending on it. Dropping only those
keeps a single wrong declaration - in a module scoped to several
commands, for example - from silencing an entire analysis.
The returned mapping gives, for each skipped module, the module which
is missing a dependency and the dependency it is missing.
"""
skipped: dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]] = {}
# Skipping the one module a run was asked for leaves nothing to run,
# which the caller reports instead.
remainder = (
"" if self.module_name else " The rest of the analysis will still run."
)
# Skipping one module can skip the modules depending on it, which the
# pass over the module list may already have gone past, so repeat the
# pass until nothing changes.
changed = True
while changed:
changed = False
for module in required:
if module in skipped:
continue
for declared, dependency in self._module_dependencies(module):
if dependency not in module_indexes:
# A replaced dependency always resolves to a module of
# this command, so an unavailable one is always the
# module class the author declared.
skipped[module] = (module, declared)
changed = True
self.log.warning(
"Module %s will be SKIPPED: it depends on module "
"%s, which is not available in this command.%s",
module.__name__,
declared.__name__,
remainder,
)
break
if dependency in skipped:
root, missing = skipped[dependency]
skipped[module] = (root, missing)
changed = True
if dependency is root:
self.log.warning(
"Module %s will be SKIPPED: it depends on "
"module %s, itself skipped for depending on "
"unavailable module %s.%s",
module.__name__,
self._dependency_name(declared, dependency),
missing.__name__,
remainder,
)
else:
self.log.warning(
"Module %s will be SKIPPED: it depends on "
"skipped module %s, in a chain starting at "
"module %s, which depends on unavailable "
"module %s.%s",
module.__name__,
self._dependency_name(declared, dependency),
root.__name__,
missing.__name__,
remainder,
)
break
return skipped
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]: def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
"""Return enabled modules in stable topological order.""" """Return enabled modules in stable topological order."""
modules = self._available_modules() modules = self._available_modules()
module_indexes = {module: index for index, module in enumerate(modules)} module_indexes = {module: index for index, module in enumerate(modules)}
selected = self._selected_modules(modules) if self.module_name:
if selected is None: selected = [
return None module for module in modules if module.__name__ == self.module_name
]
else:
selected = [module for module in modules if module.enabled]
required: set[type[MVTModule]] = set() required = set(selected)
pending = list(selected) pending = list(selected)
while pending: while pending:
module = pending.pop() module = pending.pop()
if module in required: for dependency in module.dependencies:
continue if dependency not in module_indexes:
required.add(module) self.log.warning(
for _, dependency in self._module_dependencies(module): "Module %s depends on unavailable module %s. "
# Unavailable dependencies are reported by _skipped_modules(). "No modules will be run.",
if dependency in module_indexes: module.__name__,
dependency.__name__,
)
return None
if dependency not in required:
required.add(dependency)
pending.append(dependency) pending.append(dependency)
ordered_required = sorted(required, key=lambda module: module_indexes[module])
skipped = self._skipped_modules(ordered_required, module_indexes)
runnable = [module for module in ordered_required if module not in skipped]
if skipped and not runnable:
self.log.warning(
"Every selected module was skipped for an unavailable "
"dependency. No modules will be run."
)
return None
dependents: dict[type[MVTModule], list[type[MVTModule]]] = { dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
module: [] for module in runnable module: [] for module in required
} }
indegree = {module: 0 for module in runnable} indegree = {module: 0 for module in required}
for module in runnable: for module in required:
for _, dependency in self._module_dependencies(module): for dependency in module.dependencies:
if dependency not in indegree: if dependency not in required:
continue continue
dependents[dependency].append(module) dependents[dependency].append(module)
indegree[module] += 1 indegree[module] += 1
@@ -707,7 +371,7 @@ class Command:
if indegree[dependent] == 0: if indegree[dependent] == 0:
heappush(ready, (module_indexes[dependent], dependent)) heappush(ready, (module_indexes[dependent], dependent))
if len(ordered) != len(runnable): if len(ordered) != len(required):
cyclic_modules = sorted( cyclic_modules = sorted(
(module.__name__ for module, count in indegree.items() if count > 0) (module.__name__ for module, count in indegree.items() if count > 0)
) )
@@ -720,36 +384,37 @@ class Command:
return ordered return ordered
def _run_module( def run(self) -> None:
self, ordered_modules = self._ordered_modules()
module: type[MVTModule], if ordered_modules is None:
executed_by_type: dict[type[MVTModule], MVTModule], return
capture_logs: bool,
) -> tuple[MVTModule, list[logging.LogRecord], bool]: self._log_loaded_modules(ordered_modules)
"""Initialize and run one module, optionally buffering console logs."""
token = start_module_log_buffer() if capture_logs else None
records: list[logging.LogRecord] = []
encrypted = False
try: try:
self.init()
except NotImplementedError:
pass
executed_by_type: dict[type[MVTModule], MVTModule] = {}
for module in ordered_modules:
module_logger = get_module_logger(module)
m = module( m = module(
target_path=self.target_path, target_path=self.target_path,
results_path=self.results_path, results_path=self.results_path,
module_options=self.module_options, module_options=self.module_options,
log=get_module_logger(module), log=module_logger,
) )
# Dependencies are keyed by the module class they declare, even
# when it was replaced, so that a module asking for the results
# of a replaced module receives those of its replacement.
m.dependency_modules = { m.dependency_modules = {
dependency: executed_by_type[resolved] dependency: executed_by_type[dependency]
for dependency, resolved in self._module_dependencies(module) for dependency in module.dependencies
} }
m.resource_lock = self._resource_lock
m.resource_cache = self._resource_cache
if self.iocs.total_ioc_count: if self.iocs.total_ioc_count:
# IOC collections are shared read-only during module execution.
m.indicators = self.iocs m.indicators = self.iocs
m.indicators.log = m.log
if self.serial: if self.serial:
m.serial = self.serial m.serial = self.serial
@@ -762,215 +427,23 @@ class Command:
try: try:
run_module(m) run_module(m)
except EncryptedBackupError: except EncryptedBackupError:
encrypted = True self.log.critical(
finally: "The backup appears to be encrypted. "
if token is not None: "Please decrypt it first using `mvt-ios decrypt-backup`."
records = finish_module_log_buffer(token)
return m, records, encrypted
@staticmethod
def _console_handler() -> Optional[MVTLogHandler]:
for handler in reversed(logging.getLogger("mvt").handlers):
if isinstance(handler, MVTLogHandler):
return handler
return None
def _publish_completed_modules(
self,
ordered_modules: list[type[MVTModule]],
completed: dict[type[MVTModule], MVTModule],
) -> None:
"""Expose a stable snapshot of completed modules to initialization hooks."""
self.executed = [
completed[module] for module in ordered_modules if module in completed
]
def _aggregate_modules(
self,
ordered_modules: list[type[MVTModule]],
completed: dict[type[MVTModule], MVTModule],
) -> None:
"""Aggregate results in stable topological order."""
for module in ordered_modules:
if module not in completed:
continue
instance = completed[module]
self.timeline.extend(instance.timeline)
self.url_results.extend(instance.url_results)
self.alertstore.extend(instance.alertstore.alerts)
def _run_sequential(
self, ordered_modules: list[type[MVTModule]]
) -> tuple[dict[type[MVTModule], MVTModule], bool]:
completed: dict[type[MVTModule], MVTModule] = {}
for module in ordered_modules:
instance, _, encrypted = self._run_module(module, completed, False)
if encrypted:
return completed, True
completed[module] = instance
self._publish_completed_modules(ordered_modules, completed)
return completed, False
def _run_parallel(
self, ordered_modules: list[type[MVTModule]], jobs: int
) -> tuple[dict[type[MVTModule], MVTModule], bool]:
"""Run a stable module DAG with bounded worker threads."""
completed: dict[type[MVTModule], MVTModule] = {}
scheduled: set[type[MVTModule]] = set()
running: dict[Future, type[MVTModule]] = {}
completion_queue: queue.Queue[Future] = queue.Queue()
handler = self._console_handler()
status = None
fatal = False
def ready_modules() -> list[type[MVTModule]]:
return [
module
for module in ordered_modules
if module not in scheduled
and all(
dependency in completed
for _, dependency in self._module_dependencies(module)
) )
]
def update_status() -> None:
if status is None:
return return
names = ", ".join(module.__name__ for module in running.values())
status.update(
f"Modules: {len(completed)}/{len(ordered_modules)} complete"
+ (f"; running {names}" if names else "")
)
if handler and handler.console.is_terminal: self.executed.append(m)
status = handler.console.status("") executed_by_type[module] = m
status.start() self.timeline.extend(m.timeline)
self.url_results.extend(m.url_results)
try: self.alertstore.extend(m.alertstore.alerts)
with ThreadPoolExecutor(max_workers=jobs) as executor:
while len(completed) < len(ordered_modules):
ready = ready_modules()
# An unsafe module is a scheduler barrier: it starts only
# after active workers drain and blocks later ready work.
if ready and not ready[0].parallel_safe:
if running:
future = completion_queue.get()
module = running.pop(future)
instance, records, encrypted = future.result()
if handler:
handler.emit_module_records(module.__name__, records)
if encrypted:
fatal = True
break
completed[module] = instance
self._publish_completed_modules(ordered_modules, completed)
update_status()
continue
module = ready[0]
scheduled.add(module)
if status is not None:
status.update(
f"Modules: {len(completed)}/{len(ordered_modules)} "
f"complete; running {module.__name__}"
)
instance, records, encrypted = self._run_module(
module, completed, True
)
if handler:
handler.emit_module_records(module.__name__, records)
if encrypted:
fatal = True
break
completed[module] = instance
self._publish_completed_modules(ordered_modules, completed)
update_status()
continue
for module in ready:
if len(running) >= jobs or not module.parallel_safe:
break
scheduled.add(module)
future = executor.submit(
self._run_module, module, dict(completed), True
)
running[future] = module
future.add_done_callback(completion_queue.put)
update_status()
if not running:
break
future = completion_queue.get()
module = running.pop(future)
instance, records, encrypted = future.result()
if handler:
handler.emit_module_records(module.__name__, records)
if encrypted:
fatal = True
break
completed[module] = instance
self._publish_completed_modules(ordered_modules, completed)
update_status()
if fatal:
for future in running:
future.cancel()
# The executor context waits for already-active work. Its
# results are intentionally discarded after the fatal error.
finally:
if status is not None:
status.stop()
return completed, fatal
def run(self) -> None:
self._create_storage()
self._setup_logging()
ordered_modules = self._ordered_modules()
if ordered_modules is None:
return
# Load shared indicators before touching the acquisition.
self.iocs
try:
self.init()
except NotImplementedError:
pass
self._log_loaded_modules(ordered_modules)
jobs = self.jobs
if settings.PROFILE and jobs > 1:
self.log.warning(
"MVT_PROFILE is enabled; forcing sequential module execution."
)
jobs = 1
if jobs == 1:
completed, fatal = self._run_sequential(ordered_modules)
else:
completed, fatal = self._run_parallel(ordered_modules, jobs)
self._aggregate_modules(ordered_modules, completed)
try: try:
self.finish() self.finish()
except NotImplementedError: except NotImplementedError:
pass pass
if fatal:
self.log.critical(
"The backup appears to be encrypted. "
"Please decrypt it first using `mvt-ios decrypt-backup`."
)
return
# We only store the timeline from the parent/main command # We only store the timeline from the parent/main command
if self.sub_command: if self.sub_command:
return return
+32 -89
View File
@@ -9,83 +9,34 @@ import shlex
import click import click
from click.shell_completion import get_completion_class from click.shell_completion import get_completion_class
from .help import HELP_MSG_COMPLETION
SUPPORTED_SHELLS = ("bash", "zsh", "fish") SUPPORTED_SHELLS = ("bash", "zsh", "fish")
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
COMPLETION_INSTRUCTIONS = """Shell completion for mvt, mvt-ios and mvt-android
Print one completion script covering the three commands: def completion_instructions(program_name: str) -> str:
mvt completion bash > ~/.mvt-complete.bash return f"""Shell completion for {program_name}
mvt completion zsh > ~/.mvt-complete.zsh
mkdir -p ~/.config/fish/conf.d Print a completion script:
mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish {program_name} completion bash > ~/.{program_name}-complete.bash
{program_name} completion zsh > ~/.{program_name}-complete.zsh
mkdir -p ~/.config/fish/completions
{program_name} completion fish > ~/.config/fish/completions/{program_name}.fish
Load the generated Bash script from ~/.bashrc: Load the generated Bash script from ~/.bashrc:
[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash [ -f ~/.{program_name}-complete.bash ] && . ~/.{program_name}-complete.bash
Load the generated Zsh script from ~/.zshrc: Load the generated Zsh script from ~/.zshrc:
[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-complete.zsh [ -f ~/.{program_name}-complete.zsh ] && . ~/.{program_name}-complete.zsh
Fish loads the files in ~/.config/fish/conf.d automatically. Fish loads completion files from ~/.config/fish/completions automatically.
To write these files and update the Bash/Zsh shell configuration automatically: To write these files and update Bash/Zsh shell configuration automatically:
mvt completion bash --install {program_name} completion bash --install
mvt completion zsh --install {program_name} completion zsh --install
mvt completion fish --install {program_name} completion fish --install
""" """
def _mvt_programs() -> list[tuple[str, click.Command]]:
"""Return the console script name and CLI group of every MVT program.
The three CLIs are imported here rather than at module level: mvt.cli
imports this module while it is being defined, and generating a completion
script should not make the start-up of `mvt` import the platform CLIs.
"""
from mvt.android.cli import cli as android_cli
from mvt.cli import cli as mvt_cli
from mvt.ios.cli import cli as ios_cli
return [("mvt", mvt_cli), ("mvt-ios", ios_cli), ("mvt-android", android_cli)]
@click.command(
"completion",
context_settings=CONTEXT_SETTINGS,
help=HELP_MSG_COMPLETION,
short_help="Generate or install shell completion",
)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
def completion(shell, install):
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(COMPLETION_INSTRUCTIONS)
return
if install:
script_path = install_completion_script(shell)
click.echo(
f"Installed {shell} completion for mvt, mvt-ios and mvt-android "
f"to {script_path}"
)
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads the files in ~/.config/fish/conf.d automatically.")
return
click.echo(generate_mvt_completion_script(shell))
def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str: def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str:
completion_class = get_completion_class(shell) completion_class = get_completion_class(shell)
if completion_class is None: if completion_class is None:
@@ -95,49 +46,41 @@ def generate_completion_script(cli: click.Command, program_name: str, shell: str
return completion_class(cli, {}, program_name, complete_var).source() return completion_class(cli, {}, program_name, complete_var).source()
def generate_mvt_completion_script(shell: str) -> str: def install_completion_script(
"""Return one script completing every MVT command. cli: click.Command,
program_name: str,
Click names the completion function of each program after the program, so shell: str,
the scripts of the three commands can simply be concatenated. ) -> Path:
""" script = generate_completion_script(cli, program_name, shell)
scripts = [ script_path = _completion_script_path(program_name, shell)
generate_completion_script(cli, program_name, shell).strip("\n")
for program_name, cli in _mvt_programs()
]
return "\n\n".join(scripts)
def install_completion_script(shell: str) -> Path:
script = generate_mvt_completion_script(shell)
script_path = _completion_script_path(shell)
script_path.parent.mkdir(parents=True, exist_ok=True) script_path.parent.mkdir(parents=True, exist_ok=True)
script_path.write_text(f"{script}\n", encoding="utf-8") script_path.write_text(script, encoding="utf-8")
if shell in ("bash", "zsh"): if shell in ("bash", "zsh"):
_install_shell_source_line(shell, script_path) _install_shell_source_line(program_name, shell, script_path)
return script_path return script_path
def _completion_script_path(shell: str) -> Path: def _completion_script_path(program_name: str, shell: str) -> Path:
home = Path.home() home = Path.home()
if shell == "fish": if shell == "fish":
# conf.d is sourced when the shell starts, unlike the completions return home / ".config" / "fish" / "completions" / f"{program_name}.fish"
# folder, whose files fish loads on demand by command name.
return home / ".config" / "fish" / "conf.d" / "mvt-completion.fish"
return home / f".mvt-complete.{shell}" return home / f".{program_name}-complete.{shell}"
def _install_shell_source_line(shell: str, script_path: Path) -> None: def _install_shell_source_line(program_name: str, shell: str, script_path: Path) -> None:
shell_config_path = Path.home() / f".{shell}rc" shell_config_path = Path.home() / f".{shell}rc"
source_line = ( source_line = (
f"[ -f {shlex.quote(str(script_path))} ] && " f"[ -f {shlex.quote(str(script_path))} ] && "
f". {shlex.quote(str(script_path))}" f". {shlex.quote(str(script_path))}"
) )
block = f"# MVT shell completion\n{source_line}\n" block = (
f"# MVT shell completion for {program_name}\n"
f"{source_line}\n"
)
if shell_config_path.exists(): if shell_config_path.exists():
shell_config = shell_config_path.read_text(encoding="utf-8") shell_config = shell_config_path.read_text(encoding="utf-8")
+6 -11
View File
@@ -12,9 +12,7 @@ from pydantic_settings import (
YamlConfigSettingsSource, YamlConfigSettingsSource,
) )
# MVT_CONFIG_FOLDER in the environment relocates the settings file, so that MVT_CONFIG_FOLDER = user_config_dir("mvt")
# a test run or a scripted install never touches the user's own.
MVT_CONFIG_FOLDER = os.environ.get("MVT_CONFIG_FOLDER") or user_config_dir("mvt")
MVT_CONFIG_PATH = os.path.join(MVT_CONFIG_FOLDER, "config.yaml") MVT_CONFIG_PATH = os.path.join(MVT_CONFIG_FOLDER, "config.yaml")
@@ -61,16 +59,13 @@ class MVTSettings(BaseSettings):
dotenv_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource,
) -> Tuple[PydanticBaseSettingsSource, ...]: ) -> Tuple[PydanticBaseSettingsSource, ...]:
yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH)
sources: Tuple[PydanticBaseSettingsSource, ...] = ( sources: Tuple[PydanticBaseSettingsSource, ...] = (
YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH), yaml_source,
init_settings, init_settings,
) )
# Load env variables only when asked to. initialise() constructs the # Always load env variables by default
# settings once without them so that what gets written back to sources = (env_settings,) + sources
# config.yaml never includes values taken from the environment.
# init_settings() returns the keyword arguments passed to the constructor.
if init_settings().get("load_env", True):
sources = (env_settings,) + sources
return sources return sources
def save_settings( def save_settings(
@@ -97,7 +92,7 @@ class MVTSettings(BaseSettings):
Afterwards we load the settings again, this time including the env variables. Afterwards we load the settings again, this time including the env variables.
""" """
# Construct the settings without env variables so they are not persisted. # Set invalid env prefix to avoid loading env variables.
settings = cls(load_env=False) settings = cls(load_env=False)
settings.save_settings() settings.save_settings()
+1 -14
View File
@@ -17,30 +17,17 @@ HELP_MSG_LOAD_MODULE = (
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing" HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
HELP_MSG_HASHES = "Generate hashes of all the files analyzed" HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
HELP_MSG_VERBOSE = "Verbose mode" HELP_MSG_VERBOSE = "Verbose mode"
HELP_MSG_JOBS = "Number of extraction modules to run concurrently"
HELP_MSG_VERBOSE_COMMAND = (
"Verbose mode (kept for compatibility, pass --verbose before the command "
"name instead)"
)
HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators" HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators"
HELP_MSG_STIX2 = "Download public STIX2 indicators" HELP_MSG_STIX2 = "Download public STIX2 indicators"
HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check" HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check"
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check" HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check"
HELP_MSG_COMPLETION = ( HELP_MSG_COMPLETION = "Generate or install shell completion"
"Generate or install shell completion for mvt, mvt-ios and mvt-android"
)
HELP_MSG_PLUGINS = "Inspect the installed MVT plugin packages"
HELP_MSG_PLUGINS_LIST = "List the installed plugins and what they contribute to MVT"
HELP_MSG_PLUGINS_CHECK_UPDATES = (
"Check the installed plugins for updates without installing them"
)
# IOS Specific # IOS Specific
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup" HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
HELP_MSG_BACKUP_DESTINATION = ( HELP_MSG_BACKUP_DESTINATION = (
"Path to the folder where the decrypted backup should be stored" "Path to the folder where the decrypted backup should be stored"
) )
HELP_MSG_DECRYPT_JOBS = "Number of files to decrypt concurrently"
HELP_MSG_IOS_BACKUP_PASSWORD = ( HELP_MSG_IOS_BACKUP_PASSWORD = (
"Password to use to decrypt the backup (or, set the {MVT_IOS_BACKUP_PASSWORD} " "Password to use to decrypt the backup (or, set the {MVT_IOS_BACKUP_PASSWORD} "
"environment variable)" "environment variable)"
+5 -7
View File
@@ -18,9 +18,7 @@ from appdirs import user_data_dir
from .config import settings from .config import settings
from .url import URL from .url import URL
# MVT_DATA_FOLDER in the environment relocates the downloaded indicators and MVT_DATA_FOLDER = user_data_dir("mvt")
# the update-check state kept next to them.
MVT_DATA_FOLDER = os.environ.get("MVT_DATA_FOLDER") or user_data_dir("mvt")
MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators") MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,9 +71,7 @@ class Indicators:
if os.path.isfile(path) and path.lower().endswith(".stix2"): if os.path.isfile(path) and path.lower().endswith(".stix2"):
self.parse_stix2(path) self.parse_stix2(path)
elif os.path.isdir(path): elif os.path.isdir(path):
for file in glob.glob( for file in glob.glob(os.path.join(path, "**", "*.stix2"), recursive=True):
os.path.join(path, "**", "*.stix2"), recursive=True
):
self.parse_stix2(file) self.parse_stix2(file)
else: else:
self.log.error( self.log.error(
@@ -522,7 +518,9 @@ class Indicators:
the original URL order. the original URL order.
""" """
batches = [list(urls) if urls else [] for urls in url_batches] batches = [list(urls) if urls else [] for urls in url_batches]
unique_urls = list(dict.fromkeys(url for urls in batches for url in urls)) unique_urls = list(
dict.fromkeys(url for urls in batches for url in urls)
)
if not unique_urls: if not unique_urls:
return [None] * len(batches) return [None] * len(batches)
+1 -41
View File
@@ -4,8 +4,6 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging import logging
import threading
from contextvars import ContextVar, Token
from rich.console import Console from rich.console import Console
from rich.logging import RichHandler from rich.logging import RichHandler
from typing import Optional from typing import Optional
@@ -29,33 +27,16 @@ logging.addLevelName(HIGH_ALERT, "HIGH")
logging.addLevelName(CRITICAL_ALERT, "CRITICAL") logging.addLevelName(CRITICAL_ALERT, "CRITICAL")
_module_log_buffer: ContextVar[Optional[list[logging.LogRecord]]] = ContextVar(
"mvt_module_log_buffer", default=None
)
def start_module_log_buffer() -> Token:
"""Capture console records in the current module execution context."""
return _module_log_buffer.set([])
def finish_module_log_buffer(token: Token) -> list[logging.LogRecord]:
records = _module_log_buffer.get() or []
_module_log_buffer.reset(token)
return records
class MVTLogHandler(RichHandler): class MVTLogHandler(RichHandler):
def __init__(self, console: Optional[Console] = None, level: int = logging.DEBUG): def __init__(self, console: Optional[Console] = None, level: int = logging.DEBUG):
super().__init__(console=console, level=level) super().__init__(console=console, level=level)
self._console_lock = threading.RLock()
def __add_prefix_space(self, level: str) -> str: def __add_prefix_space(self, level: str) -> str:
max_length = len("CRITICAL ALERT") max_length = len("CRITICAL ALERT")
space = max_length - len(level) space = max_length - len(level)
return f"{level}{' ' * space}" return f"{level}{' ' * space}"
def _emit_record(self, record: logging.LogRecord) -> None: def emit(self, record: logging.LogRecord):
try: try:
msg = rf"[grey50]\[{record.name}][/] {self.format(record)}" msg = rf"[grey50]\[{record.name}][/] {self.format(record)}"
@@ -82,24 +63,3 @@ class MVTLogHandler(RichHandler):
except Exception: except Exception:
self.handleError(record) self.handleError(record)
def emit(self, record: logging.LogRecord):
buffer = _module_log_buffer.get()
if buffer is not None:
# More than one CLI can be imported in-process (notably in tests),
# which can install multiple console handlers for the same record.
if not buffer or buffer[-1] is not record:
buffer.append(record)
return
with self._console_lock:
self._emit_record(record)
def emit_module_records(
self, module_name: str, records: list[logging.LogRecord]
) -> None:
"""Print one module's captured records without interleaving."""
with self._console_lock:
self.console.print(f"[bold cyan]--- {module_name} ---[/bold cyan]")
for record in records:
self._emit_record(record)
+62 -125
View File
@@ -8,125 +8,10 @@ import logging
import requests import requests
from rich import print as rich_print from rich import print as rich_print
from .config import settings from .updates import IndicatorsUpdates, MVTUpdates
from .updates import (
IndicatorsUpdates,
MVTUpdates,
PluginUpdates,
installed_plugin_distributions,
)
from .version import MVT_VERSION from .version import MVT_VERSION
def _check_version_updates(log: logging.Logger) -> None:
try:
mvt_updates = MVTUpdates()
latest_version = mvt_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t[bold]Note: Could not check for MVT updates.[/bold] "
"You may be working offline. Please update MVT regularly."
)
except Exception as e:
log.error("Error encountered when trying to check latest MVT version: %s", e)
else:
if latest_version:
rich_print(
f"\t[bold]Version {latest_version} is available! "
"Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]"
)
def _check_indicator_updates(log: logging.Logger) -> None:
ioc_updates = IndicatorsUpdates()
# Before proceeding, we check if we have downloaded an indicators index.
# If not, there's no point in proceeding with the updates check.
if ioc_updates.get_latest_update() == 0:
rich_print(
"\t[bold]You have not yet downloaded any indicators, check "
"the `download-iocs` command![/bold]"
)
return
# We only perform this check at a fixed frequency, in order to not
# overburden the user with too many lookups if the command is being run
# multiple times.
should_check, hours = ioc_updates.should_check()
if not should_check:
rich_print(
f"\tIndicators updates checked recently, next automatic check "
f"in {int(hours)} hours"
)
return
try:
ioc_to_update = ioc_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t[bold]Note: Could not check for indicator updates.[/bold] "
"You may be working offline. Please update MVT indicators regularly."
)
except Exception as e:
log.error("Error encountered when trying to check latest MVT indicators: %s", e)
else:
if ioc_to_update:
rich_print(
"\t[bold]There are updates to your indicators files! "
"Run the `download-iocs` command to update![/bold]"
)
else:
rich_print("\tYour indicators files seem to be up to date.")
def _print_plugin_updates(findings: list) -> None:
if not findings:
return
rich_print("\t[bold]Plugin updates available:[/bold]")
for finding in findings:
rich_print(
f"\t {finding['name']} {finding['installed']} → "
f"{finding['latest']} ({finding['upgrade_command']})"
)
def _check_plugin_updates(log: logging.Logger) -> None:
if not settings.NETWORK_ACCESS_ALLOWED:
return
# This runs on every command, so nothing here, including reading back what
# the latest check stored, may ever interrupt MVT.
try:
distributions = installed_plugin_distributions()
# There is nothing to check when MVT was not extended with any plugin.
if not distributions:
return
plugin_updates = PluginUpdates()
# We only perform this check at a fixed frequency, in order to not
# overburden the user (and the plugin repositories) with too many
# lookups. In between checks we print the findings of the latest one,
# leaving out those which no longer apply to what is installed.
should_check, _ = plugin_updates.should_check()
if not should_check:
_print_plugin_updates(plugin_updates.current_findings(distributions))
return
findings = plugin_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t[bold]Note: Could not check for plugin updates.[/bold] "
"You may be working offline. Please update your MVT plugins regularly."
)
except Exception as e:
log.error("Error encountered when trying to check MVT plugin updates: %s", e)
else:
_print_plugin_updates(findings)
def check_updates( def check_updates(
disable_version_check: bool = False, disable_indicator_check: bool = False disable_version_check: bool = False, disable_indicator_check: bool = False
) -> None: ) -> None:
@@ -134,25 +19,77 @@ def check_updates(
# First we check for MVT version updates. # First we check for MVT version updates.
if not disable_version_check: if not disable_version_check:
_check_version_updates(log) try:
mvt_updates = MVTUpdates()
latest_version = mvt_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t\t[bold]Note: Could not check for MVT updates.[/bold] "
"You may be working offline. Please update MVT regularly."
)
except Exception as e:
log.error(
"Error encountered when trying to check latest MVT version: %s", e
)
else:
if latest_version:
rich_print(
f"\t\t[bold]Version {latest_version} is available! "
"Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]"
)
# Then we check for indicators files updates. # Then we check for indicators files updates.
if not disable_indicator_check: if not disable_indicator_check:
_check_indicator_updates(log) ioc_updates = IndicatorsUpdates()
# Finally we check for updates to the installed plugin packages. MVT never # Before proceeding, we check if we have downloaded an indicators index.
# installs an update itself, it only reports the command which does. # If not, there's no point in proceeding with the updates check.
if not disable_version_check: if ioc_updates.get_latest_update() == 0:
_check_plugin_updates(log) rich_print(
"\t\t[bold]You have not yet downloaded any indicators, check "
"the `download-iocs` command![/bold]"
)
return
# We only perform this check at a fixed frequency, in order to not
# overburden the user with too many lookups if the command is being run
# multiple times.
should_check, hours = ioc_updates.should_check()
if not should_check:
rich_print(
f"\t\tIndicators updates checked recently, next automatic check "
f"in {int(hours)} hours"
)
return
try:
ioc_to_update = ioc_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t\t[bold]Note: Could not check for indicator updates.[/bold] "
"You may be working offline. Please update MVT indicators regularly."
)
except Exception as e:
log.error(
"Error encountered when trying to check latest MVT indicators: %s", e
)
else:
if ioc_to_update:
rich_print(
"\t\t[bold]There are updates to your indicators files! "
"Run the `download-iocs` command to update![/bold]"
)
else:
rich_print("\t\tYour indicators files seem to be up to date.")
def logo( def logo(
disable_version_check: bool = False, disable_indicator_check: bool = False disable_version_check: bool = False, disable_indicator_check: bool = False
) -> None: ) -> None:
rich_print("\n") rich_print("\n")
rich_print("\t[bold]MVT - Mobile Verification Toolkit[/bold]\n") rich_print("\t[bold]MVT[/bold] - Mobile Verification Toolkit")
rich_print("\thttps://mvt.re") rich_print("\t\thttps://mvt.re")
rich_print(f"\tVersion: {MVT_VERSION}\n") rich_print(f"\t\tVersion: {MVT_VERSION}")
check_updates(disable_version_check, disable_indicator_check) check_updates(disable_version_check, disable_indicator_check)
+8 -24
View File
@@ -8,7 +8,6 @@ import json
import logging import logging
import os import os
import re import re
import threading
from dataclasses import asdict, is_dataclass from dataclasses import asdict, is_dataclass
from typing import Any, Dict, Optional, Sequence from typing import Any, Dict, Optional, Sequence
@@ -47,12 +46,6 @@ class MVTModule:
slug: Optional[str] = None slug: Optional[str] = None
dependencies: Sequence[type["MVTModule"]] = () dependencies: Sequence[type["MVTModule"]] = ()
supported_commands: Sequence[tuple[str, str]] = () supported_commands: Sequence[tuple[str, str]] = ()
parallel_safe: bool = True
# 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
# dependencies of any other module.
replaces: Optional[type["MVTModule"]] = None
def __init__( def __init__(
self, self,
@@ -92,12 +85,6 @@ class MVTModule:
self.timeline: ModuleTimeline = [] self.timeline: ModuleTimeline = []
self.url_results: list[URLResult] = [] self.url_results: list[URLResult] = []
self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {} self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {}
# Commands replace this with a command-scoped lock for modules sharing
# archive handles or other non-thread-safe read resources.
self.resource_lock = threading.RLock()
# Commands also replace this with a command-scoped cache. Cached values
# must be immutable, because parallel modules can read them concurrently.
self.resource_cache: Dict[object, Any] = {}
def get_dependency_results( def get_dependency_results(
self, module_class: type["MVTModule"] self, module_class: type["MVTModule"]
@@ -193,20 +180,17 @@ class MVTModule:
""" """
timeline_set = set() timeline_set = set()
deduplicated = []
for record in timeline: for record in timeline:
serialized = json.dumps( timeline_set.add(
asdict(record) json.dumps(
if is_dataclass(record) and not isinstance(record, type) asdict(record)
else record, if is_dataclass(record) and not isinstance(record, type)
sort_keys=True, else record,
sort_keys=True,
)
) )
if serialized in timeline_set:
continue
timeline_set.add(serialized)
deduplicated.append(json.loads(serialized))
return deduplicated return [json.loads(record) for record in timeline_set]
def to_timeline(self) -> None: def to_timeline(self) -> None:
"""Convert results into a timeline.""" """Convert results into a timeline."""
+17 -80
View File
@@ -10,7 +10,6 @@ import inspect
import json import json
import logging import logging
import os import os
import re
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
@@ -18,7 +17,6 @@ from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Iterable, Optional from typing import Iterable, Optional
from .cli_plugins import CUSTOM_COMMAND_MODULE_PREFIX
from .module import MVTModule from .module import MVTModule
from .version import MVT_VERSION from .version import MVT_VERSION
@@ -28,7 +26,6 @@ EXTERNAL_LOGGER_NAMESPACE = "mvt.ext"
PLUGIN_PACKAGE_PREFIX = "mvt_plugin_" PLUGIN_PACKAGE_PREFIX = "mvt_plugin_"
_ORIGIN_ATTRIBUTE = "_mvt_module_origin" _ORIGIN_ATTRIBUTE = "_mvt_module_origin"
_PATH_MODULE_PREFIX = "_mvt_custom_module_" _PATH_MODULE_PREFIX = "_mvt_custom_module_"
_LOADED_FILE_DIGEST = re.compile(r"_[0-9a-f]{16}$")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -69,25 +66,6 @@ def _module_name_for_path(path: Path) -> str:
return f"{_PATH_MODULE_PREFIX}{path.stem}_{digest}" return f"{_PATH_MODULE_PREFIX}{path.stem}_{digest}"
def _is_builtin_logger_name(name: str) -> bool:
return name == "mvt" or name.startswith("mvt.")
def _loaded_file_stem(name: str, prefix: str) -> str:
"""Recover a loaded file's name from the import name MVT gave it."""
return _LOADED_FILE_DIGEST.sub("", name[len(prefix) :])
def _external_logger_name(name: str) -> str:
"""Return the "mvt.ext" logger name external code logs under."""
top_level, separator, rest = name.partition(".")
if top_level.startswith(PLUGIN_PACKAGE_PREFIX) and len(top_level) > len(
PLUGIN_PACKAGE_PREFIX
):
name = top_level[len(PLUGIN_PACKAGE_PREFIX) :] + separator + rest
return f"{EXTERNAL_LOGGER_NAMESPACE}.{name}"
def get_module_logger(module_class: type[MVTModule]) -> logging.Logger: def get_module_logger(module_class: type[MVTModule]) -> logging.Logger:
"""Return the logger a module's records should be emitted through. """Return the logger a module's records should be emitted through.
@@ -102,35 +80,19 @@ def get_module_logger(module_class: type[MVTModule]) -> logging.Logger:
"mvt_plugin_<name>" naming convention log under "mvt.ext.<name>". "mvt_plugin_<name>" naming convention log under "mvt.ext.<name>".
""" """
name = module_class.__module__ name = module_class.__module__
if _is_builtin_logger_name(name): if name == "mvt" or name.startswith("mvt."):
return logging.getLogger(name) return logging.getLogger(name)
if name.startswith(_PATH_MODULE_PREFIX): if name.startswith(_PATH_MODULE_PREFIX):
file_name = Path(get_module_origin(module_class).name).stem name = Path(get_module_origin(module_class).name).stem
return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{file_name}") else:
top_level, separator, rest = name.partition(".")
if top_level.startswith(PLUGIN_PACKAGE_PREFIX) and len(top_level) > len(
PLUGIN_PACKAGE_PREFIX
):
name = top_level[len(PLUGIN_PACKAGE_PREFIX) :] + separator + rest
return logging.getLogger(_external_logger_name(name)) return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{name}")
def get_plugin_logger(name: str) -> logging.Logger:
"""Return a general logger for use in custom MVT plugins.
Call it with ``__name__``. The logger sits under "mvt.ext". That is
where get_module_logger() puts module classes. A file loaded with
--load-module or --load-command is named after the file.
"""
if _is_builtin_logger_name(name):
return logging.getLogger(name)
# A file loaded with --load-module or --load-command is imported under a
# mangled name. Log it under the file it came from. get_module_logger()
# does the same for the module classes such a file defines.
for prefix in (_PATH_MODULE_PREFIX, CUSTOM_COMMAND_MODULE_PREFIX):
if name.startswith(prefix):
stem = _loaded_file_stem(name, prefix)
return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{stem}")
return logging.getLogger(_external_logger_name(name))
def _iter_module_files(path: Path) -> Iterable[Path]: def _iter_module_files(path: Path) -> Iterable[Path]:
@@ -217,36 +179,21 @@ def _module_key(module_class: type[MVTModule]) -> tuple[str, str]:
return (source, module_class.__qualname__) return (source, module_class.__qualname__)
def distribution_direct_url(dist: importlib.metadata.Distribution) -> Optional[dict]:
"""Return the PEP 610 direct URL metadata of a distribution, if recorded.
Packages installed from an index have no direct URL metadata, while
packages installed directly from a repository or from a local folder
record where they were installed from in ``direct_url.json``.
"""
try:
direct_url_text = dist.read_text("direct_url.json")
if not direct_url_text:
return None
direct_url = json.loads(direct_url_text)
return direct_url if isinstance(direct_url, dict) else None
except Exception:
return None
def _distribution_commit(dist: importlib.metadata.Distribution) -> Optional[str]: def _distribution_commit(dist: importlib.metadata.Distribution) -> Optional[str]:
"""Return the VCS commit a distribution was installed from, if recorded. """Return the VCS commit a distribution was installed from, if recorded.
Packages installed directly from a repository (``pip install git+...``) Packages installed directly from a repository (``pip install git+...``)
record the commit in ``direct_url.json`` (PEP 610). record the commit in ``direct_url.json`` (PEP 610).
""" """
vcs_info = (distribution_direct_url(dist) or {}).get("vcs_info") try:
if not isinstance(vcs_info, dict): direct_url_text = dist.read_text("direct_url.json")
if not direct_url_text:
return None
commit = json.loads(direct_url_text).get("vcs_info", {}).get("commit_id")
return commit if isinstance(commit, str) else None
except Exception:
return None return None
commit = vcs_info.get("commit_id")
return commit if isinstance(commit, str) else None
def _entry_point_origin(entry_point: importlib.metadata.EntryPoint) -> ModuleOrigin: def _entry_point_origin(entry_point: importlib.metadata.EntryPoint) -> ModuleOrigin:
name = entry_point.name name = entry_point.name
@@ -401,14 +348,4 @@ def module_supports_command(
) )
return False return False
pairs = {tuple(entry) for entry in supported_commands} return (platform, command) in {tuple(entry) for entry in supported_commands}
if (platform, command) in pairs:
return True
# A module which implements check_indicators() is re-checked by check-iocs
# for its platform. It does not need to declare the check-iocs pair.
return (
command == "check-iocs"
and platform in {entry[0] for entry in pairs if entry}
and module_class.check_indicators is not MVTModule.check_indicators
)
-299
View File
@@ -1,299 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import contextlib
import json
import os
import re
import tempfile
from typing import Any, ClassVar, Dict, List, Tuple, Type, TypeVar
import yaml
from appdirs import user_config_dir, user_data_dir
from pydantic import ValidationError
from pydantic_settings import (
BaseSettings,
EnvSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
YamlConfigSettingsSource,
)
PLUGIN_CONFIG_FOLDER_NAME = "plugins"
# Not "plugins": on macOS the configuration and data folders are the same
# directory, and that name already holds the settings files.
PLUGIN_DATA_FOLDER_NAME = "plugin-data"
PLUGIN_ENV_PREFIX = "MVT_PLUGIN_"
PLUGIN_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
PluginSettingsType = TypeVar("PluginSettingsType", bound="MVTPluginSettings")
class PluginConfigLoadError(Exception):
pass
def validate_plugin_name(plugin_name: Any) -> str:
"""
Check that a plugin name is safe to use in a file name and an env variable.
:param plugin_name: Name to validate.
:returns: The validated plugin name.
"""
if not isinstance(plugin_name, str):
raise TypeError(
f"Plugin name must be a string, not {type(plugin_name).__name__}"
)
if not PLUGIN_NAME_PATTERN.fullmatch(plugin_name):
raise ValueError(
f"Invalid plugin name {plugin_name!r}: plugin names must start with a "
"lowercase letter or a digit and may only contain lowercase letters, "
"digits and dashes"
)
return plugin_name
def plugin_config_folder() -> str:
"""
Return the folder where plugins store their configuration files.
The path is resolved on every call so it always reflects the current
environment.
"""
return os.path.join(user_config_dir("mvt"), PLUGIN_CONFIG_FOLDER_NAME)
def plugin_config_path(plugin_name: str) -> str:
"""
Return the path of the configuration file of a given plugin.
:param plugin_name: Name of the plugin.
"""
return os.path.join(
plugin_config_folder(), f"{validate_plugin_name(plugin_name)}.yaml"
)
def plugin_data_folder(plugin_name: str) -> str:
"""
Return the folder where a given plugin stores its data, creating it.
Plugins should keep whatever they persist, such as caches or downloaded
artifacts, in this folder. It is created with owner-only permissions. The
path is resolved on every call so it always reflects the current
environment. A plugin with a settings class calls
`MVTPluginSettings.data_folder()` instead, which passes `plugin_name` here.
:param plugin_name: Name of the plugin.
:returns: The path of the data folder of the plugin.
"""
# Validate the name before anything is created, so an unsafe name cannot
# leave a folder behind.
name = validate_plugin_name(plugin_name)
# makedirs() applies its mode only to the last folder of the path, so
# MVT's own data folder keeps the default permissions while the two
# plugin folders are private.
data_folder = os.path.join(user_data_dir("mvt"), PLUGIN_DATA_FOLDER_NAME)
os.makedirs(data_folder, mode=0o700, exist_ok=True)
folder = os.path.join(data_folder, name)
os.makedirs(folder, mode=0o700, exist_ok=True)
return folder
def plugin_env_prefix(plugin_name: str) -> str:
"""
Return the environment variable prefix used by a given plugin.
Dashes are replaced by underscores. Plugin names cannot contain underscores,
so two different plugin names never share an environment namespace.
:param plugin_name: Name of the plugin.
"""
name = validate_plugin_name(plugin_name).upper().replace("-", "_")
return f"{PLUGIN_ENV_PREFIX}{name}_"
def _settings_plugin_name(settings_cls: Type[BaseSettings]) -> str:
plugin_name = getattr(settings_cls, "plugin_name", None)
if plugin_name is None:
raise TypeError(
f"{settings_cls.__name__} must set a 'plugin_name' class attribute to "
"namespace its configuration file and environment variables"
)
return validate_plugin_name(plugin_name)
def _plugin_yaml_source(
settings_cls: Type[BaseSettings], config_path: str
) -> YamlConfigSettingsSource:
"""
Build the YAML settings source of a plugin, reporting unusable files.
A missing file is not an error, but a file which cannot be parsed or which
does not hold a mapping of setting names is reported with its path.
"""
try:
return YamlConfigSettingsSource(settings_cls, config_path)
except yaml.YAMLError as exc:
raise PluginConfigLoadError(
f"Invalid plugin configuration file {config_path}: {exc}"
) from exc
except (TypeError, ValueError) as exc:
raise PluginConfigLoadError(
f"Invalid plugin configuration file {config_path}: the file must "
"contain a mapping of setting names to values"
) from exc
class MVTPluginSettings(BaseSettings):
"""
Base class for plugin-namespaced settings.
Subclass with typed fields and set `plugin_name`. Values resolve from
constructor arguments, then environment variables (MVT_PLUGIN_<NAME>_*),
then the plugin's YAML file (~/.config/mvt/plugins/<name>.yaml), then field
defaults.
Plugins must not store their settings in MVT's own configuration file: MVT
rewrites it with the fields it knows about, dropping anything else.
`data_folder()` returns the folder the plugin keeps its data in.
"""
model_config = SettingsConfigDict(extra="ignore")
plugin_name: ClassVar[str]
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
plugin_name = _settings_plugin_name(cls)
# Namespace the environment variables of this plugin. Each pydantic
# model gets its own configuration dictionary, so this does not leak
# into other plugins.
cls.model_config["env_prefix"] = plugin_env_prefix(plugin_name)
@classmethod
def settings_customise_sources(
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> Tuple[PydanticBaseSettingsSource, ...]:
config_path = plugin_config_path(_settings_plugin_name(settings_cls))
yaml_source = _plugin_yaml_source(settings_cls, config_path)
# Explicit arguments take precedence over environment variables, which
# in turn take precedence over the configuration file.
return (init_settings, env_settings, yaml_source)
@classmethod
def load(cls: Type[PluginSettingsType]) -> PluginSettingsType:
"""
Load the settings of the plugin.
A missing configuration file is not an error: the settings then come
from the environment and from the field defaults.
"""
return cls()
@classmethod
def data_folder(cls) -> str:
"""
Return the data folder of the plugin, creating it.
The folder is the one plugin_data_folder() returns for `plugin_name`,
so a plugin with a settings class does not repeat its name.
"""
return plugin_data_folder(_settings_plugin_name(cls))
def _environment_values(self) -> Dict[str, Any]:
"""
Return the settings values currently supplied by the environment.
Values are validated by the model, so they can be compared with the
values held by this instance.
"""
settings_cls = type(self)
raw_values = EnvSettingsSource(settings_cls)()
names = [name for name in raw_values if name in settings_cls.model_fields]
if not names:
return {}
# Fall back on the current values for the fields the environment does
# not set, so that required fields do not fail validation here.
current_values = json.loads(self.model_dump_json())
try:
from_environment = settings_cls.model_validate(
{**current_values, **raw_values}
)
except ValidationError:
# An environment variable which the model cannot validate must not
# stop the other environment values from being recognised, or a
# credential would be written to the configuration file.
return self._environment_values_by_field(current_values, raw_values, names)
return {name: getattr(from_environment, name) for name in names}
def _environment_values_by_field(
self,
current_values: Dict[str, Any],
raw_values: Dict[str, Any],
names: List[str],
) -> Dict[str, Any]:
"""
Validate each environment value on its own, skipping unusable ones.
:param current_values: Serialized values held by this instance.
:param raw_values: Values supplied by the environment.
:param names: Names of the fields set by the environment.
"""
settings_cls = type(self)
values = {}
for name in names:
try:
from_environment = settings_cls.model_validate(
{**current_values, name: raw_values[name]}
)
except ValidationError:
continue
values[name] = getattr(from_environment, name)
return values
def save(self) -> None:
"""
Save the current settings to the configuration file of the plugin.
Only values which differ from the field defaults are persisted. Values
which come from the environment are not written to disk, so credentials
passed as environment variables stay out of the configuration file.
MVT's own configuration file is never modified.
"""
config_folder = plugin_config_folder()
if not os.path.isdir(config_folder):
os.makedirs(config_folder, mode=0o700, exist_ok=True)
values = json.loads(self.model_dump_json(exclude_defaults=True))
for name, environment_value in self._environment_values().items():
if name in values and getattr(self, name, None) == environment_value:
del values[name]
# Settings files can hold credentials, so write them through a private
# temporary file and move it in place. The file is then never partially
# written and never briefly readable by other users.
config_path = plugin_config_path(self.plugin_name)
descriptor, temporary_path = tempfile.mkstemp(
dir=config_folder, prefix=f".{self.plugin_name}-", suffix=".yaml"
)
try:
with os.fdopen(descriptor, "w") as config_file:
config_file.write(yaml.dump(values, default_flow_style=False))
os.replace(temporary_path, config_path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(temporary_path)
raise
+3 -416
View File
@@ -3,13 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import importlib.metadata
import json
import logging import logging
import os import os
import re
import shlex
import subprocess
from datetime import datetime from datetime import datetime
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -17,35 +12,14 @@ import requests
import yaml import yaml
from packaging import version from packaging import version
from .cli_plugins import (
ANDROID_CLI_PLUGIN_GROUP,
IOS_CLI_PLUGIN_GROUP,
NEUTRAL_CLI_PLUGIN_GROUP,
)
from .config import settings from .config import settings
from .indicators import MVT_DATA_FOLDER, MVT_INDICATORS_FOLDER from .indicators import MVT_DATA_FOLDER, MVT_INDICATORS_FOLDER
from .module_loader import MODULES_ENTRY_POINT_GROUP, distribution_direct_url
from .version import MVT_VERSION from .version import MVT_VERSION
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# In hours. # In hours.
INDICATORS_CHECK_FREQUENCY = 12 INDICATORS_CHECK_FREQUENCY = 12
PLUGINS_CHECK_FREQUENCY = 12
# The entry-point groups a package can use to extend MVT.
PLUGIN_ENTRY_POINT_GROUPS = (
MODULES_ENTRY_POINT_GROUP,
IOS_CLI_PLUGIN_GROUP,
ANDROID_CLI_PLUGIN_GROUP,
NEUTRAL_CLI_PLUGIN_GROUP,
)
SHORT_COMMIT_LENGTH = 8
# The keys every cached finding has to carry to be printed.
FINDING_KEYS = ("name", "installed", "latest", "upgrade_command")
# Options which stop ssh from waiting for an answer nobody is there to give.
_SSH_BATCH_OPTIONS = ("-o", "BatchMode=yes", "-o", "ConnectTimeout=10")
_COMMIT_PATTERN = re.compile(r"\A[0-9a-f]{7,40}\Z")
class MVTUpdates: class MVTUpdates:
@@ -64,395 +38,6 @@ class MVTUpdates:
return "" return ""
def installed_plugin_distributions() -> list[importlib.metadata.Distribution]:
"""Return the installed distributions which extend MVT.
A plugin package is any distribution registering at least one entry point
in the module or CLI command groups. Distributions are returned once each,
sorted by name. MVT itself is not a plugin and is never returned.
"""
distributions: dict[str, importlib.metadata.Distribution] = {}
for group in PLUGIN_ENTRY_POINT_GROUPS:
try:
entry_points = importlib.metadata.entry_points(group=group)
except Exception as exc:
log.warning(
"Unable to discover installed plugin packages in entry-point "
"group %s: %s",
group,
exc,
)
continue
for entry_point in entry_points:
# Manually constructed entry points have no associated distribution.
dist = getattr(entry_point, "dist", None)
if dist is None:
continue
try:
name = dist.name
except Exception:
continue
if not name or name == "mvt":
continue
distributions.setdefault(name, dist)
return [distributions[name] for name in sorted(distributions)]
def _is_usable_finding(finding: object) -> bool:
"""Check that a cached finding carries everything needed to print it."""
if not isinstance(finding, dict):
return False
return all(
isinstance(finding.get(key), str) and finding.get(key) for key in FINDING_KEYS
)
def _installed_revision(
dist: importlib.metadata.Distribution, origin: object
) -> Optional[str]:
"""Return what a plugin's installed revision is right now.
This is the value a finding recorded as installed when it was made, so a
finding can be compared against the current state of the installation
without looking anything up remotely.
"""
try:
if origin != "git":
return dist.version
vcs_info = (distribution_direct_url(dist) or {}).get("vcs_info")
if not isinstance(vcs_info, dict):
return None
commit = vcs_info.get("commit_id") or ""
return commit[:SHORT_COMMIT_LENGTH] or None
except Exception as e:
log.debug("Failed to read the installed revision of a plugin: %s", e)
return None
def _batch_mode_ssh_command(ssh_command: str) -> str:
"""Return an ssh command line which cannot stop to ask a question.
ssh keeps the first value it is given for a keyword, so the batch mode
options are inserted right after the ssh program, ahead of whatever the
analyst configured. Their remaining options, such as which key to use,
still apply.
"""
try:
arguments = shlex.split(ssh_command.strip())
except ValueError:
arguments = []
if not arguments:
arguments = ["ssh"]
return shlex.join([arguments[0], *_SSH_BATCH_OPTIONS, *arguments[1:]])
def _revision_pins_commit(revision: str, commit: str) -> bool:
"""Check whether a requested revision pins the installed commit."""
candidate = revision.lower()
if not _COMMIT_PATTERN.match(candidate):
return False
return commit.lower().startswith(candidate)
class PluginUpdates:
"""Check for updates to the installed MVT plugin packages.
MVT never installs or upgrades a plugin package itself. It only reports
the command which upgrades a plugin, leaving the analyst to decide when to
run it.
"""
@property
def latest_check_path(self) -> str:
return os.path.join(MVT_DATA_FOLDER, "latest_plugins_check")
@property
def findings_path(self) -> str:
return os.path.join(MVT_DATA_FOLDER, "plugin_updates.json")
def _create_data_folder(self) -> None:
if not os.path.exists(MVT_DATA_FOLDER):
os.makedirs(MVT_DATA_FOLDER)
def get_latest_check(self) -> int:
if not os.path.exists(self.latest_check_path):
return 0
# A corrupt or truncated timestamp only means the next check happens
# sooner. It must never stop MVT from running.
try:
with open(self.latest_check_path, "r", encoding="utf-8") as handle:
data = handle.read().strip()
if data:
return int(data)
except (OSError, ValueError) as e:
log.debug("Failed to read the time of the latest plugin check: %s", e)
return 0
def set_latest_check(self) -> None:
self._create_data_folder()
timestamp = int(datetime.now().timestamp())
with open(self.latest_check_path, "w", encoding="utf-8") as handle:
handle.write(str(timestamp))
def get_findings(self) -> list[dict]:
"""
Return the findings of the latest check, without checking again.
Returns an empty list if no check was ever performed.
"""
if not os.path.exists(self.findings_path):
return []
try:
with open(self.findings_path, "r", encoding="utf-8") as handle:
findings = json.load(handle)
except Exception as e:
log.debug("Failed to read the cached plugin updates: %s", e)
return []
if not isinstance(findings, list):
return []
# Anything which does not look like a finding is dropped rather than
# trusted: the cache is only a convenience.
return [finding for finding in findings if _is_usable_finding(finding)]
def current_findings(
self, distributions: Optional[list[importlib.metadata.Distribution]] = None
) -> list[dict]:
"""
Return the cached findings which still apply to what is installed.
Findings about a plugin which was upgraded or removed since the latest
check are dropped, so an update is never reported twice.
"""
if distributions is None:
distributions = installed_plugin_distributions()
installed = {}
for dist in distributions:
try:
installed[dist.name] = dist
except Exception:
continue
current = []
for finding in self.get_findings():
plugin = installed.get(finding["name"])
if plugin is None:
continue
if (
_installed_revision(plugin, finding.get("origin"))
!= finding["installed"]
):
continue
current.append(finding)
return current
def set_findings(self, findings: list[dict]) -> None:
self._create_data_folder()
with open(self.findings_path, "w", encoding="utf-8") as handle:
json.dump(findings, handle)
def should_check(self) -> Tuple[bool, int]:
"""
Compare time of the latest plugins check with current time.
Returns bool and number of hours since the last check.
"""
now = datetime.now()
latest_check_ts = self.get_latest_check()
latest_check_dt = datetime.fromtimestamp(latest_check_ts)
diff = now - latest_check_dt
diff_hours = divmod(diff.total_seconds(), 3600)[0]
if diff_hours >= PLUGINS_CHECK_FREQUENCY:
return True, 0
return False, int(PLUGINS_CHECK_FREQUENCY - diff_hours)
def _check_index_plugin(self, name: str, installed: str) -> Optional[dict]:
"""Check a plugin installed from a package index for a newer release."""
url = f"https://pypi.org/pypi/{name}/json"
try:
res = requests.get(url, timeout=settings.NETWORK_TIMEOUT)
except requests.exceptions.RequestException as e:
log.debug("Failed to check for updates to plugin %s: %s", name, e)
return None
# Plugins which were never published to a public index are expected,
# and there is nothing to compare their version against.
if res.status_code == 404:
return None
if res.status_code != 200:
log.debug(
"Failed to check for updates to plugin %s (error %d)",
name,
res.status_code,
)
return None
try:
latest = res.json().get("info", {}).get("version", "")
if not latest or version.parse(latest) <= version.parse(installed):
return None
except Exception as e:
log.debug("Failed to compare the versions of plugin %s: %s", name, e)
return None
return {
"name": name,
"installed": installed,
"latest": latest,
"origin": "pypi",
# The name comes from package metadata, so the command MVT
# suggests is quoted rather than assumed to be shell-safe.
"upgrade_command": f"pip install -U {shlex.quote(name)}",
}
def _git_ls_remote(self, url: str, revision: str) -> list[Tuple[str, str]]:
"""Return the remote references matching a revision, if git allows it."""
# Neither value is trusted: they are read from the metadata of an
# installed package and must not turn into git options.
if url.startswith("-") or revision.startswith("-"):
log.debug("Skipping the update check for the invalid repository %s", url)
return []
environment = dict(os.environ)
# Never prompt the analyst for repository credentials. git handles its
# own prompts, while ssh reads the terminal directly and only batch
# mode makes it fail instead of asking.
environment["GIT_TERMINAL_PROMPT"] = "0"
environment["GIT_SSH_COMMAND"] = _batch_mode_ssh_command(
environment.get("GIT_SSH_COMMAND", "")
)
try:
process = subprocess.run(
["git", "ls-remote", url, revision],
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
env=environment,
timeout=settings.NETWORK_TIMEOUT,
check=False,
)
except FileNotFoundError:
log.debug("Could not find git, skipping the update check for %s", url)
return []
except (subprocess.SubprocessError, OSError) as e:
log.debug("Failed to query the repository %s: %s", url, e)
return []
if process.returncode != 0:
log.debug(
"Failed to query the repository %s (error %d): %s",
url,
process.returncode,
(process.stderr or "").strip(),
)
return []
references = []
for line in (process.stdout or "").splitlines():
commit, _, reference = line.partition("\t")
if commit.strip() and reference.strip():
references.append((commit.strip(), reference.strip()))
return references
def _check_repository_plugin(
self, name: str, direct_url: dict, vcs_info: dict
) -> Optional[dict]:
"""Check a plugin installed from a repository for a newer commit."""
url = direct_url.get("url") or ""
installed = vcs_info.get("commit_id") or ""
revision = vcs_info.get("requested_revision") or ""
if not url or not installed:
return None
# A plugin installed from a commit is pinned and never goes out of
# date, no matter what the branch it came from does next.
if revision and _revision_pins_commit(revision, installed):
return None
latest = ""
wanted_reference = f"refs/heads/{revision}" if revision else "HEAD"
for commit, reference in self._git_ls_remote(url, revision or "HEAD"):
# Tags are pinned installs too.
if reference.startswith("refs/tags/"):
return None
if reference == wanted_reference:
latest = commit
if not latest or latest == installed:
return None
requirement = f"{name} @ git+{url}"
if revision:
requirement += f"@{revision}"
return {
"name": name,
"installed": installed[:SHORT_COMMIT_LENGTH],
"latest": latest[:SHORT_COMMIT_LENGTH],
"origin": "git",
# A repository URL and a branch name can both hold characters a
# shell would act on, so the requirement is quoted for the shell
# the analyst is going to paste the command into.
"upgrade_command": f"pip install -U {shlex.quote(requirement)}",
}
def _check_distribution(
self, dist: importlib.metadata.Distribution
) -> Optional[dict]:
try:
name = dist.name
installed = dist.version
except Exception as e:
log.debug("Failed to read the metadata of an installed plugin: %s", e)
return None
direct_url = distribution_direct_url(dist)
if direct_url is None:
return self._check_index_plugin(name, installed)
vcs_info = direct_url.get("vcs_info")
if isinstance(vcs_info, dict):
return self._check_repository_plugin(name, direct_url, vcs_info)
# Plugins installed from a local folder, including editable installs,
# are maintained by the analyst and have nothing to check against.
return None
def check(self) -> list[dict]:
"""
Check every installed plugin package for an available update.
Returns one entry per plugin which can be upgraded.
"""
findings = []
for dist in installed_plugin_distributions():
finding = self._check_distribution(dist)
if finding:
findings.append(finding)
self.set_findings(findings)
self.set_latest_check()
return findings
class IndicatorsUpdates: class IndicatorsUpdates:
def __init__(self) -> None: def __init__(self) -> None:
self.github_raw_url = "https://raw.githubusercontent.com/{}/{}/{}/{}" self.github_raw_url = "https://raw.githubusercontent.com/{}/{}/{}/{}"
@@ -595,7 +180,9 @@ class IndicatorsUpdates:
def _get_remote_file_latest_commit( def _get_remote_file_latest_commit(
self, owner: str, repo: str, branch: str, path: str self, owner: str, repo: str, branch: str, path: str
) -> int: ) -> int:
file_commit_url = f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}" file_commit_url = (
f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}"
)
try: try:
res = requests.get(file_commit_url, timeout=5) res = requests.get(file_commit_url, timeout=5)
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
+1 -3
View File
@@ -349,9 +349,7 @@ class URL:
return tld_obj return tld_obj
if tld_obj is None: if tld_obj is None:
return "" return ""
# removeprefix, not lstrip: lstrip takes a set of characters, so it ate the return tld_obj.parsed_url.netloc.lower().lstrip("www.")
# leading "w"s and dots of any domain ("web.evil.com" -> "eb.evil.com").
return tld_obj.parsed_url.netloc.lower().removeprefix("www.")
def get_top_level(self) -> str: def get_top_level(self) -> str:
"""Get only the top-level domain from a URL. """Get only the top-level domain from a URL.
+6 -15
View File
@@ -14,6 +14,7 @@ from dataclasses import asdict, is_dataclass
from typing import Any, Iterator, Union from typing import Any, Iterator, Union
from .log import MVTLogHandler from .log import MVTLogHandler
from mvt.common.config import settings
class CustomJSONEncoder(json.JSONEncoder): class CustomJSONEncoder(json.JSONEncoder):
@@ -255,26 +256,16 @@ def init_logging(verbose: bool = False):
def set_verbose_logging(verbose: bool = False): def set_verbose_logging(verbose: bool = False):
"""Raise or lower the verbosity of MVT's console output.
Only MVT's own console handler is adjusted, wherever it sits in the list.
The file handler a command attaches to its output folder keeps recording
everything, so the command.log of a run does not depend on how the run was
invoked, and a handler attached to the "mvt" logger by anything else is
left alone.
"""
log = logging.getLogger("mvt") log = logging.getLogger("mvt")
for handler in log.handlers: handler = log.handlers[0]
if isinstance(handler, MVTLogHandler): if verbose:
handler.setLevel(logging.DEBUG if verbose else logging.INFO) handler.setLevel(logging.DEBUG)
else:
handler.setLevel(logging.INFO)
def exec_or_profile(module, globals, locals): def exec_or_profile(module, globals, locals):
"""Hook for profiling MVT modules""" """Hook for profiling MVT modules"""
# Imported here so that the CLI modules, which import this one at start-up,
# do not load the settings (and pydantic) before a command runs.
from .config import settings
if settings.PROFILE: if settings.PROFILE:
cProfile.runctx(module, globals, locals) cProfile.runctx(module, globals, locals)
else: else:
+1 -3
View File
@@ -3,6 +3,4 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from importlib.metadata import version MVT_VERSION = "2026.7.29"
MVT_VERSION = version("mvt")
+1 -1
View File
@@ -3,4 +3,4 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .cli import cli, main from .cli import cli
+83 -97
View File
@@ -14,7 +14,16 @@ from mvt.common.cli_plugins import (
load_cli_commands_option, load_cli_commands_option,
register_cli_plugins, register_cli_plugins,
) )
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.logo import logo
from mvt.common.options import MutuallyExclusiveOption from mvt.common.options import MutuallyExclusiveOption
from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import ( from mvt.common.utils import (
generate_hashes_from_path, generate_hashes_from_path,
init_logging, init_logging,
@@ -24,7 +33,6 @@ from mvt.common.help import (
HELP_MSG_VERSION, HELP_MSG_VERSION,
HELP_MSG_DECRYPT_BACKUP, HELP_MSG_DECRYPT_BACKUP,
HELP_MSG_BACKUP_DESTINATION, HELP_MSG_BACKUP_DESTINATION,
HELP_MSG_DECRYPT_JOBS,
HELP_MSG_IOS_BACKUP_PASSWORD, HELP_MSG_IOS_BACKUP_PASSWORD,
HELP_MSG_BACKUP_KEYFILE, HELP_MSG_BACKUP_KEYFILE,
HELP_MSG_HASHES, HELP_MSG_HASHES,
@@ -36,8 +44,6 @@ from mvt.common.help import (
HELP_MSG_LOAD_MODULE, HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE, HELP_MSG_MODULE,
HELP_MSG_VERBOSE, HELP_MSG_VERBOSE,
HELP_MSG_JOBS,
HELP_MSG_VERBOSE_COMMAND,
HELP_MSG_CHECK_FS, HELP_MSG_CHECK_FS,
HELP_MSG_CHECK_IOCS, HELP_MSG_CHECK_IOCS,
HELP_MSG_STIX2, HELP_MSG_STIX2,
@@ -45,17 +51,17 @@ from mvt.common.help import (
HELP_MSG_CHECK_SYSDIAGNOSE, HELP_MSG_CHECK_SYSDIAGNOSE,
HELP_MSG_DISABLE_UPDATE_CHECK, HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_COMPLETION,
) )
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.password import prompt_password from mvt.common.password import prompt_password
from .decrypt_config import ( from .cmd_check_backup import CmdIOSCheckBackup
DEFAULT_DECRYPT_WORKERS, from .cmd_check_fs import CmdIOSCheckFS
MAX_DECRYPT_WORKERS, from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
) from .decrypt import DecryptBackup
from .modules.backup import BACKUP_MODULES
# The commands import what they run only when they are invoked. This module is from .modules.fs import FS_MODULES
# imported at every start of mvt-ios, including by shell completion on every from .modules.mixed import MIXED_MODULES
# keystroke, so importing it must do no more than build the command tree: the
# forensic modules, the backup decryption and the update checks stay out of it.
init_logging() init_logging()
log = logging.getLogger("mvt") log = logging.getLogger("mvt")
@@ -75,14 +81,7 @@ def _get_disable_flags(ctx):
) )
def _get_verbose(ctx):
"""Return whether --verbose was passed to the CLI itself."""
return bool(ctx.obj and ctx.obj.get("verbose", False))
def _load_custom_modules(load_module): def _load_custom_modules(load_module):
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
try: try:
return load_custom_modules(load_module) return load_custom_modules(load_module)
except CustomModuleLoadError as exc: except CustomModuleLoadError as exc:
@@ -102,31 +101,60 @@ def _load_custom_modules(load_module):
is_flag=True, is_flag=True,
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
) )
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.pass_context @click.pass_context
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose): def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check ctx.obj["disable_indicator_check"] = disable_indicator_update_check
ctx.obj["verbose"] = verbose if ctx.invoked_subcommand != "completion":
set_verbose_logging(verbose) logo(
disable_version_check=disable_update_check,
from mvt.common.logo import logo disable_indicator_check=disable_indicator_update_check,
)
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ============================================================================== # ==============================================================================
# Command: version # Command: version
# ============================================================================== # ==============================================================================
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION) @cli.command("version", help=HELP_MSG_VERSION)
def version(): def version():
return return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-ios"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ============================================================================== # ==============================================================================
# Command: decrypt-backup # Command: decrypt-backup
# ============================================================================== # ==============================================================================
@@ -134,13 +162,6 @@ def version():
"decrypt-backup", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_DECRYPT_BACKUP "decrypt-backup", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_DECRYPT_BACKUP
) )
@click.option("--destination", "-d", required=True, help=HELP_MSG_BACKUP_DESTINATION) @click.option("--destination", "-d", required=True, help=HELP_MSG_BACKUP_DESTINATION)
@click.option(
"--jobs",
type=click.IntRange(1, MAX_DECRYPT_WORKERS),
default=DEFAULT_DECRYPT_WORKERS,
show_default=True,
help=HELP_MSG_DECRYPT_JOBS,
)
@click.option( @click.option(
"--password", "--password",
"-p", "-p",
@@ -159,10 +180,8 @@ def version():
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def decrypt_backup(ctx, destination, jobs, password, key_file, hashes, backup_path): def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
from .decrypt import DecryptBackup backup = DecryptBackup(backup_path, destination)
backup = DecryptBackup(backup_path, destination, max_workers=jobs)
if key_file: if key_file:
if MVT_IOS_BACKUP_PASSWORD in os.environ: if MVT_IOS_BACKUP_PASSWORD in os.environ:
@@ -225,8 +244,6 @@ def decrypt_backup(ctx, destination, jobs, password, key_file, hashes, backup_pa
) )
@click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.argument("BACKUP_PATH", type=click.Path(exists=True))
def extract_key(password, key_file, backup_path): def extract_key(password, key_file, backup_path):
from .decrypt import DecryptBackup
backup = DecryptBackup(backup_path) backup = DecryptBackup(backup_path)
if password: if password:
@@ -279,10 +296,7 @@ def extract_key(password, key_file, backup_path):
help=HELP_MSG_LOAD_MODULE, help=HELP_MSG_LOAD_MODULE,
) )
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_backup( def check_backup(
@@ -295,12 +309,9 @@ def check_backup(
load_module, load_module,
hashes, hashes,
verbose, verbose,
jobs,
backup_path, backup_path,
): ):
from .cmd_check_backup import CmdIOSCheckBackup set_verbose_logging(verbose)
set_verbose_logging(verbose or _get_verbose(ctx))
module_options = {"fast_mode": fast} module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
@@ -314,7 +325,6 @@ def check_backup(
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
if list_modules: if list_modules:
@@ -324,6 +334,8 @@ def check_backup(
if not cmd.resolve_backup_path(): if not cmd.resolve_backup_path():
ctx.exit(1) ctx.exit(1)
log.info("Checking iTunes backup located at: %s", cmd.target_path)
cmd.run() cmd.run()
cmd.show_alerts_brief() cmd.show_alerts_brief()
cmd.show_support_message() cmd.show_support_message()
@@ -353,10 +365,7 @@ def check_backup(
help=HELP_MSG_LOAD_MODULE, help=HELP_MSG_LOAD_MODULE,
) )
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("DUMP_PATH", type=click.Path(exists=True)) @click.argument("DUMP_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_fs( def check_fs(
@@ -369,12 +378,9 @@ def check_fs(
load_module, load_module,
hashes, hashes,
verbose, verbose,
jobs,
dump_path, dump_path,
): ):
from .cmd_check_fs import CmdIOSCheckFS set_verbose_logging(verbose)
set_verbose_logging(verbose or _get_verbose(ctx))
module_options = {"fast_mode": fast} module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
@@ -388,13 +394,14 @@ def check_fs(
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
return return
log.info("Checking iOS filesystem located at: %s", dump_path)
cmd.run() cmd.run()
cmd.show_alerts_brief() cmd.show_alerts_brief()
cmd.show_support_message() cmd.show_support_message()
@@ -427,7 +434,7 @@ def check_fs(
help=HELP_MSG_LOAD_MODULE, help=HELP_MSG_LOAD_MODULE,
) )
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True)) @click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_sysdiagnose( def check_sysdiagnose(
@@ -441,9 +448,7 @@ def check_sysdiagnose(
verbose, verbose,
sysdiagnose_path, sysdiagnose_path,
): ):
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose set_verbose_logging(verbose)
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckSysdiagnose( cmd = CmdIOSCheckSysdiagnose(
target_path=sysdiagnose_path, target_path=sysdiagnose_path,
@@ -456,20 +461,18 @@ def check_sysdiagnose(
custom_modules=custom_modules, custom_modules=custom_modules,
) )
# MVT's own module only records the device details; the checks come from if not cmd._available_modules():
# custom modules, so a run without any must not look like a clean analysis. raise click.ClickException(
if all(module in cmd.modules for module in cmd._available_modules()): "No custom modules support mvt-ios check-sysdiagnose. "
log.warning( "Load a module that declares supported_commands = "
"No forensic sysdiagnose modules have been loaded: MVT's own " "((\"ios\", \"check-sysdiagnose\"),)."
"SysdiagnoseInfo module only records the device details. Install a "
"module package or load a module that declares supported_commands = "
'(("ios", "check-sysdiagnose"),) to check the sysdiagnose.'
) )
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
return return
log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path)
cmd.run() cmd.run()
cmd.show_alerts_brief() cmd.show_alerts_brief()
cmd.show_support_message() cmd.show_support_message()
@@ -499,10 +502,6 @@ def check_sysdiagnose(
@click.argument("FOLDER", type=click.Path(exists=True)) @click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_iocs(ctx, iocs, list_modules, module, load_module, folder): def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from .command_modules import IOS_CHECK_IOCS_MODULES
custom_modules = _load_custom_modules(load_module) custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS( cmd = CmdCheckIOCS(
target_path=folder, target_path=folder,
@@ -513,7 +512,7 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
custom_modules=custom_modules, custom_modules=custom_modules,
platform="ios", platform="ios",
) )
cmd.modules = IOS_CHECK_IOCS_MODULES cmd.modules = BACKUP_MODULES + FS_MODULES + MIXED_MODULES
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
@@ -529,25 +528,12 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
# ============================================================================== # ==============================================================================
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2) @cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
def download_iocs(): def download_iocs():
from mvt.common.updates import IndicatorsUpdates
ioc_updates = IndicatorsUpdates() ioc_updates = IndicatorsUpdates()
ioc_updates.update() ioc_updates.update()
# ============================================================================== register_cli_plugins(
# Entry point of the mvt-ios console script cli,
# ============================================================================== entry_point_group=IOS_CLI_PLUGIN_GROUP,
def main() -> None: environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV,
"""Register the external commands and run the mvt-ios CLI. )
External commands are registered here rather than when this module is
imported, so that importing MVT never runs third-party code and a plugin
importing from MVT cannot re-enter a module that is still initializing.
"""
register_cli_plugins(
cli,
entry_point_group=IOS_CLI_PLUGIN_GROUP,
environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV,
)
cli()
-5
View File
@@ -38,7 +38,6 @@ class CmdIOSCheckBackup(Command):
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None, custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -54,16 +53,12 @@ class CmdIOSCheckBackup(Command):
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
self.platform = "ios" self.platform = "ios"
self.name = "check-backup" self.name = "check-backup"
self.modules = BACKUP_MODULES + MIXED_MODULES self.modules = BACKUP_MODULES + MIXED_MODULES
def init(self) -> None:
self.log.info("Checking iTunes backup located at: %s", self.target_path)
def resolve_backup_path(self) -> bool: def resolve_backup_path(self) -> bool:
target_path = getattr(self, "target_path", None) target_path = getattr(self, "target_path", None)
if not isinstance(target_path, str) or not target_path: if not isinstance(target_path, str) or not target_path:
-5
View File
@@ -31,7 +31,6 @@ class CmdIOSCheckFS(Command):
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None, custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -46,15 +45,11 @@ class CmdIOSCheckFS(Command):
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules, custom_modules=custom_modules,
jobs=jobs,
) )
self.platform = "ios" self.platform = "ios"
self.name = "check-fs" self.name = "check-fs"
self.modules = FS_MODULES + MIXED_MODULES self.modules = FS_MODULES + MIXED_MODULES
def init(self) -> None:
self.log.info("Checking iOS filesystem located at: %s", self.target_path)
def module_init(self, module): def module_init(self, module):
module.is_fs_dump = True module.is_fs_dump = True
+2 -27
View File
@@ -7,9 +7,7 @@ import json
import logging import logging
import os import os
import shutil import shutil
import sys
import tarfile import tarfile
import zlib
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any, Optional from typing import Any, Optional
@@ -18,8 +16,6 @@ from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule from mvt.common.module import MVTModule
from .modules.sysdiagnose import SYSDIAGNOSE_MODULES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -56,7 +52,6 @@ class CmdIOSCheckSysdiagnose(Command):
) )
self.platform = "ios" self.platform = "ios"
self.name = "check-sysdiagnose" self.name = "check-sysdiagnose"
self.modules = SYSDIAGNOSE_MODULES
self.sysdiagnose_format: Optional[str] = None self.sysdiagnose_format: Optional[str] = None
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
self.sysdiagnose_files: list[str] = [] self.sysdiagnose_files: list[str] = []
@@ -81,15 +76,11 @@ class CmdIOSCheckSysdiagnose(Command):
if not self.target_path: if not self.target_path:
raise ValueError("A sysdiagnose path is required") raise ValueError("A sysdiagnose path is required")
self.log.info("Checking iOS sysdiagnose at path: %s", self.target_path)
if os.path.isdir(self.target_path): if os.path.isdir(self.target_path):
self.sysdiagnose_format = "dir" self.sysdiagnose_format = "dir"
parent_path = Path(self.target_path).absolute().parent parent_path = Path(self.target_path).absolute().parent
for root, _, filenames in os.walk(self.target_path): for root, _, filenames in os.walk(self.target_path):
for filename in filenames: for filename in filenames:
if filename.startswith("._"):
continue
absolute_path = os.path.join(root, filename) absolute_path = os.path.join(root, filename)
file_path = os.path.relpath(absolute_path, parent_path) file_path = os.path.relpath(absolute_path, parent_path)
self.sysdiagnose_files.append(file_path) self.sysdiagnose_files.append(file_path)
@@ -103,19 +94,8 @@ class CmdIOSCheckSysdiagnose(Command):
self.log.info("Parsing sysdiagnose archive. This might take a while...") self.log.info("Parsing sysdiagnose archive. This might take a while...")
self.sysdiagnose_format = "tar" self.sysdiagnose_format = "tar"
try: self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz") self._extract_sysdiagnose_archive()
self._extract_sysdiagnose_archive()
except (tarfile.ReadError, EOFError, zlib.error, OSError) as exc:
# A truncated archive ends in EOFError from gzip, which Click would
# otherwise report as a bare "Aborted!" with no reason.
self.log.critical(
"Unable to read the sysdiagnose archive %s: %s. "
"The file may be truncated or not a gzip-compressed tarball.",
self.target_path,
exc,
)
sys.exit(1)
def _extract_sysdiagnose_archive(self) -> None: def _extract_sysdiagnose_archive(self) -> None:
archive = self.sysdiagnose_archive archive = self.sysdiagnose_archive
@@ -139,11 +119,6 @@ class CmdIOSCheckSysdiagnose(Command):
if not member_path.parts: if not member_path.parts:
continue continue
# AppleDouble sidecars (._name) carry a file's extended attributes,
# not sysdiagnose content. Device archives hold hundreds of them;
# bsdtar hides them from listings, tarfile does not.
if member_path.name.startswith("._"):
continue
archive_roots.add(member_path.parts[0]) archive_roots.add(member_path.parts[0])
if member.isdir(): if member.isdir():
-23
View File
@@ -1,23 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Module lists an mvt-ios command composes from more than one family.
Commands whose modules are one family read that family directly. check-iocs
re-checks stored results, so it has to know every module that could have
written one, and both the CLI and any other code needing that answer share
the list from here rather than each concatenating their own.
"""
from mvt.common.module import MVTModule
from .modules.backup import BACKUP_MODULES
from .modules.fs import FS_MODULES
from .modules.mixed import MIXED_MODULES
from .modules.sysdiagnose import SYSDIAGNOSE_MODULES
IOS_CHECK_IOCS_MODULES: list[type[MVTModule]] = (
BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES
)
-12
View File
@@ -1271,17 +1271,5 @@
{ {
"version": "26.6.1", "version": "26.6.1",
"build": "23G83" "build": "23G83"
},
{
"version": "26.6.2",
"build": "23G90"
},
{
"version": "27.0",
"build": "24A437"
},
{
"version": "27.0.1",
"build": "24A446"
} }
] ]
+74 -292
View File
@@ -6,179 +6,18 @@
import binascii import binascii
import glob import glob
import logging import logging
import multiprocessing
import os import os
import os.path import os.path
import plistlib
import shutil import shutil
import sqlite3 import sqlite3
import tempfile
from concurrent.futures import (
ALL_COMPLETED,
FIRST_COMPLETED,
Future,
ThreadPoolExecutor,
wait,
)
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from iphone_backup_decrypt import EncryptedBackup from iOSbackup import iOSbackup
from iphone_backup_decrypt import google_iphone_dataprotection
from iphone_backup_decrypt.utils import FilePlist
from .decrypt_config import DEFAULT_DECRYPT_WORKERS, MAX_DECRYPT_WORKERS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Import pbkdf2_hmac from the same source iphone_backup_decrypt uses internally,
# so our key derivation is consistent with theirs.
try:
from fastpbkdf2 import pbkdf2_hmac
except ImportError:
import Crypto.Hash.SHA1
import Crypto.Hash.SHA256
import Crypto.Protocol.KDF
_HASH_FNS = {"sha1": Crypto.Hash.SHA1, "sha256": Crypto.Hash.SHA256}
def pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None):
return Crypto.Protocol.KDF.PBKDF2(
password, salt, dklen, iterations, hmac_hash_module=_HASH_FNS[hash_name]
)
class MVTEncryptedBackup(EncryptedBackup):
"""Extends EncryptedBackup with derived key export/import.
NOTE: This subclass relies on internal APIs of iphone_backup_decrypt
(specifically _read_and_unlock_keybag, _keybag, and the Keybag class
internals). Pinned to iphone_backup_decrypt==0.10.0.
"""
def __init__(self, *, backup_directory, passphrase=None, derived_key=None):
if passphrase:
super().__init__(backup_directory=backup_directory, passphrase=passphrase)
self._derived_key = None # Will be set after keybag unlock
elif derived_key:
self._init_without_passphrase(backup_directory, derived_key)
else:
raise ValueError("Either passphrase or derived_key must be provided")
def _init_without_passphrase(self, backup_directory, derived_key):
"""Replicate parent __init__ state without requiring a passphrase."""
self.decrypted = False
self._backup_directory = os.path.expandvars(backup_directory)
self._passphrase = None
self._manifest_plist_path = os.path.join(
self._backup_directory, "Manifest.plist"
)
self._manifest_plist = None
self._manifest_db_path = os.path.join(self._backup_directory, "Manifest.db")
self._keybag = None
self._unlocked = False
self._temporary_folder = tempfile.mkdtemp()
self._temp_decrypted_manifest_db_path = os.path.join(
self._temporary_folder, "Manifest.db"
)
self._temp_manifest_db_conn = None
self._derived_key = derived_key # 32 raw bytes
def _read_and_unlock_keybag(self):
"""Override to capture derived key on password unlock, or use
a pre-derived key to skip PBKDF2."""
if self._unlocked:
return self._unlocked
with open(self._manifest_plist_path, "rb") as infile:
self._manifest_plist = plistlib.load(infile)
self._keybag = google_iphone_dataprotection.Keybag(
self._manifest_plist["BackupKeyBag"]
)
if self._derived_key:
# Skip PBKDF2, unwrap class keys directly with pre-derived key
self._unlocked = _unlock_keybag_with_derived_key(
self._keybag, self._derived_key
)
else:
# Normal path: full PBKDF2 derivation, capturing the intermediate key
self._unlocked, self._derived_key = _unlock_keybag_and_capture_key(
self._keybag, self._passphrase
)
self._passphrase = None
if not self._unlocked:
raise ValueError("Failed to decrypt keys: incorrect passphrase?")
return True
def get_decryption_key(self):
"""Return derived key as hex string (64 chars / 32 bytes)."""
if self._derived_key is None:
raise ValueError("No derived key available")
return self._derived_key.hex()
def extract_file_by_id(self, *, file_id, file_bplist, output_filename):
"""Extract one manifest entry without loading the whole file into memory."""
self._read_and_unlock_keybag()
file_plist = FilePlist(file_bplist)
if file_plist.encryption_key is None:
source_filename = os.path.join(
self._backup_directory, file_id[:2], file_id
)
shutil.copy2(source_filename, output_filename)
return
inner_key = self._keybag.unwrapKeyForClass(
file_plist.protection_class, file_plist.encryption_key
)
self._decrypt_file_to_disk(
file_id=file_id,
key=inner_key,
file_plist=file_plist,
output_filepath=output_filename,
)
def _unlock_keybag_with_derived_key(keybag, passphrase_key):
"""Unlock keybag class keys using a pre-derived passphrase_key,
skipping the expensive PBKDF2 rounds."""
WRAP_PASSPHRASE = 2
for classkey in keybag.classKeys.values():
if b"WPKY" not in classkey:
continue
if classkey[b"WRAP"] & WRAP_PASSPHRASE:
k = google_iphone_dataprotection._AESUnwrap(
passphrase_key, classkey[b"WPKY"]
)
if not k:
return False
classkey[b"KEY"] = k
return True
def _unlock_keybag_and_capture_key(keybag, passphrase):
"""Run full PBKDF2 key derivation and AES unwrap, returning
(success, passphrase_key) so the derived key can be exported."""
passphrase_round1 = pbkdf2_hmac(
"sha256", passphrase, keybag.attrs[b"DPSL"], keybag.attrs[b"DPIC"], 32
)
passphrase_key = pbkdf2_hmac(
"sha1", passphrase_round1, keybag.attrs[b"SALT"], keybag.attrs[b"ITER"], 32
)
WRAP_PASSPHRASE = 2
for classkey in keybag.classKeys.values():
if b"WPKY" not in classkey:
continue
if classkey[b"WRAP"] & WRAP_PASSPHRASE:
k = google_iphone_dataprotection._AESUnwrap(
passphrase_key, classkey[b"WPKY"]
)
if not k:
return False, None
classkey[b"KEY"] = k
return True, passphrase_key
class DecryptBackup: class DecryptBackup:
"""This class provides functions to decrypt an encrypted iTunes backup """This class provides functions to decrypt an encrypted iTunes backup
@@ -187,68 +26,19 @@ class DecryptBackup:
""" """
def __init__( def __init__(self, backup_path: str, dest_path: Optional[str] = None) -> None:
self,
backup_path: str,
dest_path: Optional[str] = None,
max_workers: int = DEFAULT_DECRYPT_WORKERS,
) -> None:
"""Decrypts an encrypted iOS backup. """Decrypts an encrypted iOS backup.
:param backup_path: Path to the encrypted backup folder :param backup_path: Path to the encrypted backup folder
:param dest_path: Path to the folder where to store the decrypted backup :param dest_path: Path to the folder where to store the decrypted backup
""" """
self.backup_path = os.path.abspath(backup_path) self.backup_path = os.path.abspath(backup_path)
self.dest_path = dest_path self.dest_path = dest_path
if not 1 <= max_workers <= MAX_DECRYPT_WORKERS: self._backup = None
raise ValueError(f"max_workers must be between 1 and {MAX_DECRYPT_WORKERS}") self._decryption_key = None
self.max_workers = max_workers
self._backup: Optional[MVTEncryptedBackup] = None
self._decryption_key: Optional[str] = None
def can_process(self) -> bool: def can_process(self) -> bool:
return self._backup is not None return self._backup is not None
def _process_file(
self,
*,
file_id: str,
file_bplist: bytes,
output_path: Path,
relative_path: str,
domain: str,
) -> None:
assert self._backup is not None
self._backup.extract_file_by_id(
file_id=file_id,
file_bplist=file_bplist,
output_filename=str(output_path),
)
log.info(
"Decrypted file %s [%s] to %s/%s",
relative_path,
domain,
output_path.parent,
file_id,
)
@staticmethod
def _wait_for_files(
pending: dict[Future[None], str], *, all_files: bool = False
) -> None:
if not pending:
return
done, _ = wait(
pending,
return_when=ALL_COMPLETED if all_files else FIRST_COMPLETED,
)
for future in done:
relative_path = pending.pop(future)
try:
future.result()
except Exception as exc:
log.error("Failed to decrypt file %s: %s", relative_path, exc)
@staticmethod @staticmethod
def is_encrypted(backup_path: str) -> bool: def is_encrypted(backup_path: str) -> bool:
"""Query Manifest.db file to see if it's encrypted or not. """Query Manifest.db file to see if it's encrypted or not.
@@ -268,6 +58,21 @@ class DecryptBackup:
finally: finally:
conn.close() conn.close()
def _process_file(
self, relative_path: str, domain: str, item, file_id: str, item_folder: str
) -> None:
assert self._backup is not None
self._backup.getFileDecryptedCopy(
manifestEntry=item, targetName=file_id, targetFolder=item_folder
)
log.info(
"Decrypted file %s [%s] to %s/%s",
relative_path,
domain,
item_folder,
file_id,
)
def process_backup(self) -> None: def process_backup(self) -> None:
assert self._backup is not None assert self._backup is not None
assert self.dest_path is not None assert self.dest_path is not None
@@ -276,71 +81,53 @@ class DecryptBackup:
os.makedirs(self.dest_path) os.makedirs(self.dest_path)
manifest_path = os.path.join(self.dest_path, "Manifest.db") manifest_path = os.path.join(self.dest_path, "Manifest.db")
# Extract a decrypted Manifest.db to the destination folder. # We extract a decrypted Manifest.db.
self._backup.save_manifest_file(output_filename=manifest_path) self._backup.getManifestDB()
# We store it to the destination folder.
shutil.copy(self._backup.manifestDB, manifest_path)
# Iterate over all files in the backup and decrypt them, pool = multiprocessing.Pool(multiprocessing.cpu_count())
# preserving the XX/file_id directory structure that downstream
# modules expect.
backup_root = Path(self.backup_path).resolve()
dest_root = Path(self.dest_path).resolve()
pending: dict[Future[None], str] = {}
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
with self._backup.manifest_db_cursor() as cur:
cur.execute(
"SELECT fileID, domain, relativePath, file FROM Files WHERE flags=1"
)
for file_id, domain, relative_path, file_bplist in cur:
# This may be a partial backup. Skip files from the manifest
# which do not exist locally.
source_file_path = backup_root / file_id[:2] / file_id
if not source_file_path.resolve().is_relative_to(backup_root):
log.warning("Skipping unsafe file_id: %r", file_id)
continue
if not os.path.exists(source_file_path):
log.debug(
"Skipping file %s. File not found in encrypted "
"backup directory.",
source_file_path,
)
continue
output_path = dest_root / file_id[:2] / file_id for item in self._backup.getBackupFilesList():
if not output_path.resolve().is_relative_to(dest_root): try:
log.warning("Skipping unsafe file_id: %r", file_id) file_id = item["backupFile"]
continue relative_path = item["relativePath"]
output_path.parent.mkdir(parents=True, exist_ok=True) domain = item["domain"]
if self.max_workers == 1: # This may be a partial backup. Skip files from the manifest
try: # which do not exist locally.
self._process_file( source_file_path = os.path.join(self.backup_path, file_id[0:2], file_id)
file_id=file_id, if not Path(source_file_path).resolve().is_relative_to(Path(self.backup_path).resolve()):
file_bplist=file_bplist, log.warning("Skipping unsafe file_id: %r", file_id)
output_path=output_path, continue
relative_path=relative_path, if not os.path.exists(source_file_path):
domain=domain, log.debug(
) "Skipping file %s. File not found in encrypted backup directory.",
except Exception as exc: source_file_path,
log.error(
"Failed to decrypt file %s: %s",
relative_path,
exc,
)
continue
future = executor.submit(
self._process_file,
file_id=file_id,
file_bplist=file_bplist,
output_path=output_path,
relative_path=relative_path,
domain=domain,
) )
pending[future] = relative_path continue
if len(pending) >= self.max_workers:
self._wait_for_files(pending)
self._wait_for_files(pending, all_files=True) item_folder = os.path.join(self.dest_path, file_id[0:2]) # type: ignore[arg-type]
if not Path(os.path.join(item_folder, file_id)).resolve().is_relative_to(Path(self.dest_path).resolve()):
log.warning("Skipping unsafe file_id: %r", file_id)
continue
if not os.path.exists(item_folder):
os.makedirs(item_folder)
# iOSBackup getFileDecryptedCopy() claims to read a "file"
# parameter but the code actually is reading the "manifest" key.
# Add manifest plist to both keys to handle this.
item["manifest"] = item["file"]
pool.apply_async(
self._process_file,
args=(relative_path, domain, item, file_id, item_folder),
)
except Exception as exc:
log.error("Failed to decrypt file %s: %s", relative_path, exc)
pool.close()
pool.join()
# Copying over the root plist files as well. # Copying over the root plist files as well.
for file_name in os.listdir(self.backup_path): for file_name in os.listdir(self.backup_path):
@@ -381,23 +168,20 @@ class DecryptBackup:
return return
try: try:
self._backup = MVTEncryptedBackup( self._backup = iOSbackup(
backup_directory=self.backup_path, udid=os.path.basename(self.backup_path),
passphrase=password, cleartextpassword=password,
backuproot=os.path.dirname(self.backup_path),
) )
# Eagerly trigger keybag unlock so wrong-password errors
# surface here rather than later during process_backup().
self._backup.test_decryption()
except Exception as exc: except Exception as exc:
self._backup = None
if ( if (
isinstance(exc, ValueError) isinstance(exc, KeyError)
and "passphrase" in str(exc).lower() and len(exc.args) > 0
and exc.args[0] == b"KEY"
): ):
log.critical("Failed to decrypt backup. Password is probably wrong.") log.critical("Failed to decrypt backup. Password is probably wrong.")
elif ( elif (
isinstance(exc, FileNotFoundError) isinstance(exc, FileNotFoundError)
and hasattr(exc, "filename")
and os.path.basename(exc.filename) == "Manifest.plist" and os.path.basename(exc.filename) == "Manifest.plist"
): ):
log.critical( log.critical(
@@ -440,14 +224,12 @@ class DecryptBackup:
try: try:
key_bytes_raw = binascii.unhexlify(key_bytes) key_bytes_raw = binascii.unhexlify(key_bytes)
self._backup = MVTEncryptedBackup( self._backup = iOSbackup(
backup_directory=self.backup_path, udid=os.path.basename(self.backup_path),
derived_key=key_bytes_raw, derivedkey=key_bytes_raw,
backuproot=os.path.dirname(self.backup_path),
) )
# Eagerly trigger keybag unlock so wrong-key errors surface here.
self._backup.test_decryption()
except Exception as exc: except Exception as exc:
self._backup = None
log.exception(exc) log.exception(exc)
log.critical( log.critical(
"Failed to decrypt backup. Did you provide the correct key file?" "Failed to decrypt backup. Did you provide the correct key file?"
@@ -458,7 +240,7 @@ class DecryptBackup:
if not self._backup: if not self._backup:
return return
self._decryption_key = self._backup.get_decryption_key() self._decryption_key = self._backup.getDecryptionKey()
log.info( log.info(
'Derived decryption key for backup at path %s is: "%s"', 'Derived decryption key for backup at path %s is: "%s"',
self.backup_path, self.backup_path,
-9
View File
@@ -1,9 +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/
"""Configuration shared by the iOS CLI and backup decryption implementation."""
DEFAULT_DECRYPT_WORKERS = 4
MAX_DECRYPT_WORKERS = 32
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more