mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-22 15:18:26 +02:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecc22f54b7 | ||
|
|
90fc0ae351 | ||
|
|
dab82e38d0 | ||
|
|
4e031cd43f | ||
|
|
dac4acb180 | ||
|
|
dd8bd2cb01 | ||
|
|
30c11f68c7 | ||
|
|
a92ec7f963 | ||
|
|
10a5bccbe0 | ||
|
|
3b21c9347c | ||
|
|
0fdc2c34b0 | ||
|
|
0ee25edf0a | ||
|
|
fa24b5465b | ||
|
|
683b8ba133 | ||
|
|
7b64463727 | ||
|
|
47fd771b2a | ||
|
|
8123db26d9 | ||
|
|
d92a60c9be | ||
|
|
0b48d9fe1d | ||
|
|
067f053627 | ||
|
|
93b7fb5232 | ||
|
|
8617e0bf54 | ||
|
|
53fb12aee8 | ||
|
|
8140e350f7 | ||
|
|
f483223e23 | ||
|
|
2dfe3cbcb1 | ||
|
|
3eff0c550d | ||
|
|
797411e1e5 | ||
|
|
84df51c518 | ||
|
|
a6c3a805d8 | ||
|
|
123c9081ed | ||
|
|
5ed8b3c1a5 | ||
|
|
afcfda4720 | ||
|
|
911115b0c8 | ||
|
|
c806fd8d61 | ||
|
|
516ba06cf7 | ||
|
|
1532578b39 | ||
|
|
f5b0a3cd91 | ||
|
|
a18e632ec8 | ||
|
|
2689176c0e | ||
|
|
638937e838 | ||
|
|
b9f13b8146 | ||
|
|
58558fdfb7 | ||
|
|
7c7e332e7b | ||
|
|
a232c988f9 | ||
|
|
2f4123222f | ||
|
|
81a7bc977b | ||
|
|
3b20bd944b | ||
|
|
6bbb5957af | ||
|
|
6a6c1758c3 | ||
|
|
174ce08812 | ||
|
|
d590706e62 | ||
|
|
08e6a0eae2 | ||
|
|
1e67a343ca |
No files matched your search
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.5.0
|
||||
- uses: actions/add-to-project@v2
|
||||
with:
|
||||
# You can target a project in a different organization
|
||||
# to the issue
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Mypy
|
||||
on: workflow_dispatch
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mypy_py3:
|
||||
@@ -8,13 +11,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v10.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Install Dependencies
|
||||
|
||||
@@ -36,10 +36,10 @@ jobs:
|
||||
tag-suffix: "-android"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
# 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
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: |
|
||||
@@ -59,21 +59,24 @@ jobs:
|
||||
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=sha,suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
|
||||
# Register emulators so Buildx can build the ARM64 images on the AMD64 runner.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
# This step sets up some additional capabilities to generate the provenance and sbom attestations
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
file: ${{ matrix.platform.dockerfile }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
|
||||
@@ -12,13 +12,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v10.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Install Dependencies
|
||||
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v10.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Python dependencies
|
||||
|
||||
@@ -11,10 +11,12 @@ jobs:
|
||||
update-ios-version:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
- name: Install script dependencies
|
||||
run: python -m pip install "packaging==26.3"
|
||||
- name: Run script to fetch latest iOS releases from Apple RSS feed.
|
||||
run: python3 .github/workflows/scripts/update-ios-releases.py
|
||||
run: python .github/workflows/scripts/update-ios-releases.py
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
|
||||
+13
-2
@@ -119,12 +119,23 @@ ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
adb \
|
||||
binutils \
|
||||
default-jre-headless \
|
||||
file \
|
||||
jq \
|
||||
less \
|
||||
libcurl4 \
|
||||
libimage-exiftool-perl \
|
||||
libssl3 \
|
||||
libusb-1.0-0 \
|
||||
moreutils \
|
||||
p7zip-full \
|
||||
python3 \
|
||||
sqlite3
|
||||
ripgrep \
|
||||
sqlite3 \
|
||||
tree \
|
||||
unzip \
|
||||
xxd
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
@@ -144,7 +155,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2
|
||||
RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc
|
||||
|
||||
# Generate adb key folder
|
||||
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc
|
||||
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc
|
||||
RUN mkdir /root/.android
|
||||
|
||||
# Setup investigations environment
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2
|
||||
RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc
|
||||
|
||||
# Generate adb key folder
|
||||
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc
|
||||
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc
|
||||
RUN mkdir /root/.android
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/mvt-android" ]
|
||||
@@ -60,6 +60,33 @@ For alternative installation options and known issues, please refer to the [docu
|
||||
|
||||
MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
|
||||
|
||||
### Shell completion
|
||||
|
||||
MVT can generate shell completion scripts for Bash, Zsh, and Fish:
|
||||
|
||||
```bash
|
||||
mvt-ios completion
|
||||
mvt-android completion
|
||||
```
|
||||
|
||||
The commands print setup instructions by default. To generate a completion script directly, pass the shell name:
|
||||
|
||||
```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.
|
||||
Module-running `check-*` commands can load custom Python modules with
|
||||
`--load-module PATH` or from a folder set in `MVT_CUSTOM_MODULES`. See the
|
||||
[development documentation](https://docs.mvt.re/en/latest/development/) for
|
||||
details.
|
||||
|
||||
Users can also add top-level commands to `mvt-ios` and `mvt-android` from
|
||||
installed Python packages or local files and folders. See the
|
||||
[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
|
||||
for the plugin entry points and `--load-command` interface.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -57,3 +57,9 @@ If the backup is encrypted, ABE will prompt you to enter the password.
|
||||
Alternatively, [ab-decrypt](https://github.com/joernheissler/ab-decrypt) can be used for that purpose.
|
||||
|
||||
You can then extract SMSs with MVT by passing the folder path as parameter instead of the `.ab` file: `mvt-android check-backup --output /path/to/results/ /path/to/backup/` (the path to backup given should be the folder containing the `apps` folder).
|
||||
|
||||
When an output folder is specified, URLs extracted from SMS and MMS messages
|
||||
are also written to `urls.json`. Each entry contains the URL, its expanded
|
||||
destination when MVT resolved a shortened URL during indicator checking, the
|
||||
message timestamp, and the `sms` source. The same file is created by
|
||||
`check-androidqf` when its nested Android backup contains messages with URLs.
|
||||
@@ -38,6 +38,22 @@ By separating artifact collection from forensic analysis, this approach ensures
|
||||
|
||||
For more information, refer to the [AndroidQF project documentation](https://github.com/mvt-project/androidqf).
|
||||
|
||||
### VirusTotal package lookups
|
||||
|
||||
AndroidQF records APK file hashes in `packages.json`. MVT can optionally look up non-system APK hashes on VirusTotal while checking an AndroidQF acquisition:
|
||||
|
||||
```bash
|
||||
MVT_VT_API_KEY=<key> mvt-android check-androidqf --virustotal /path/to/androidqf-output
|
||||
```
|
||||
|
||||
The `--virustotal` option is disabled by default because it sends APK hashes to VirusTotal and requires network access. It uses the `VT_API_KEY` MVT configuration value, which can also be provided through the `MVT_VT_API_KEY` environment variable.
|
||||
|
||||
To avoid exhausting free VirusTotal API quotas, MVT waits 16 seconds between package hash requests by default. Use `--delay` to change the delay, or `--delay 0` to disable throttling:
|
||||
|
||||
```bash
|
||||
mvt-android check-androidqf --virustotal --delay 30 /path/to/androidqf-output
|
||||
```
|
||||
|
||||
## Android Intrusion Logs
|
||||
|
||||
On devices where the user has opted into Android's [**Advanced Protection Mode**](https://support.google.com/android/answer/16339980) and turned on the optional Intrusion Logging featrue, Android can create and archive structured *Intrusion Logs* in an encrypted format. These logs record DNS queries, outbound network connections, process starts, ADB activity and other security-relevant events, and are a high-fidelity complement to the rest of an AndroidQF acquisition. The logs are generated on-device and encrypted before being stored in the Google account associated with the device. The encryption key is protected by the user device PIN. The intrusion log data is not accessible to Google.
|
||||
|
||||
+36
-13
@@ -1,43 +1,66 @@
|
||||
# Command Completion
|
||||
# Command Completion
|
||||
|
||||
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
|
||||
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
|
||||
|
||||
Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fish.
|
||||
|
||||
To enable it, you need to manually register a special function 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.
|
||||
|
||||
The following describes how to generate the command completion scripts and add them 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.**
|
||||
|
||||
### For Bash
|
||||
|
||||
```bash
|
||||
# Generates bash completion scripts
|
||||
echo "$(_MVT_IOS_COMPLETE=bash_source mvt-ios)" > ~/.mvt-ios-complete.bash &&
|
||||
echo "$(_MVT_ANDROID_COMPLETE=bash_source mvt-android)" > ~/.mvt-android-complete.bash
|
||||
# Generate bash completion scripts
|
||||
mvt-ios completion bash > ~/.mvt-ios-complete.bash
|
||||
mvt-android completion bash > ~/.mvt-android-complete.bash
|
||||
```
|
||||
|
||||
Add the following to `~/.bashrc`:
|
||||
```bash
|
||||
# source mvt completion scripts
|
||||
. ~/.mvt-ios-complete.bash && . ~/.mvt-android-complete.bash
|
||||
[ -f ~/.mvt-ios-complete.bash ] && . ~/.mvt-ios-complete.bash
|
||||
[ -f ~/.mvt-android-complete.bash ] && . ~/.mvt-android-complete.bash
|
||||
```
|
||||
|
||||
### For Zsh
|
||||
|
||||
```bash
|
||||
# Generates zsh completion scripts
|
||||
echo "$(_MVT_IOS_COMPLETE=zsh_source mvt-ios)" > ~/.mvt-ios-complete.zsh &&
|
||||
echo "$(_MVT_ANDROID_COMPLETE=zsh_source mvt-android)" > ~/.mvt-android-complete.zsh
|
||||
# Generate zsh completion scripts
|
||||
mvt-ios completion zsh > ~/.mvt-ios-complete.zsh
|
||||
mvt-android completion zsh > ~/.mvt-android-complete.zsh
|
||||
```
|
||||
|
||||
Add the following to `~/.zshrc`:
|
||||
```bash
|
||||
# source mvt completion scripts
|
||||
. ~/.mvt-ios-complete.zsh && . ~/.mvt-android-complete.zsh
|
||||
[ -f ~/.mvt-ios-complete.zsh ] && . ~/.mvt-ios-complete.zsh
|
||||
[ -f ~/.mvt-android-complete.zsh ] && . ~/.mvt-android-complete.zsh
|
||||
```
|
||||
|
||||
### For Fish
|
||||
|
||||
```bash
|
||||
# Generate fish completion scripts
|
||||
mkdir -p ~/.config/fish/completions
|
||||
mvt-ios completion fish > ~/.config/fish/completions/mvt-ios.fish
|
||||
mvt-android completion fish > ~/.config/fish/completions/mvt-android.fish
|
||||
```
|
||||
|
||||
Fish loads completion files from `~/.config/fish/completions` automatically.
|
||||
|
||||
### Automatic Installation
|
||||
|
||||
MVT can write the completion file and update the relevant shell configuration for Bash and Zsh when you pass `--install`:
|
||||
|
||||
```bash
|
||||
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/completions`.
|
||||
|
||||
For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion).
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Development
|
||||
|
||||
The Mobile Verification Toolkit team welcomes contributions of new forensic modules or other contributions which help improve the software.
|
||||
|
||||
## Local environment
|
||||
|
||||
MVT uses `uv` for dependency management. To install the project and development dependencies from the locked environment, run:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `ruff` and `mypy`. All can
|
||||
be run automatically with:
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
Run these tests before making new commits or opening pull requests.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
```bash
|
||||
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
||||
```
|
||||
|
||||
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Custom CLI Commands
|
||||
|
||||
MVT can load additional top-level commands into `mvt-ios` and `mvt-android`.
|
||||
Custom commands are different from [custom forensic modules](index.md#custom-modules):
|
||||
commands add new CLI operations, while modules add analysis steps to existing
|
||||
`check-*` commands.
|
||||
|
||||
!!! warning
|
||||
|
||||
Custom commands run as trusted Python code inside the MVT process. Install
|
||||
or load commands only from sources you trust. MVT does not sandbox
|
||||
third-party commands, and the MVT maintainers do not maintain them.
|
||||
|
||||
## Install a Command Package
|
||||
|
||||
Python packages can register a Click command or group for either MVT CLI. A
|
||||
minimal package can expose this command from `my_mvt_plugin.py`:
|
||||
|
||||
```python
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("path", type=click.Path(exists=True))
|
||||
def summarize(path):
|
||||
"""Summarize an acquisition."""
|
||||
click.echo(f"Summarizing {path}")
|
||||
```
|
||||
|
||||
Register the object in the package's `pyproject.toml`. The entry-point name is
|
||||
the command users invoke:
|
||||
|
||||
```toml
|
||||
[project.entry-points."mvt.ios.cli_plugins"]
|
||||
summarize = "my_mvt_plugin:summarize"
|
||||
|
||||
[project.entry-points."mvt.android.cli_plugins"]
|
||||
summarize = "my_mvt_plugin:summarize"
|
||||
```
|
||||
|
||||
Use only the iOS or Android group if the command is platform-specific. After
|
||||
installing the package in the same environment as MVT, it appears directly in
|
||||
the appropriate CLI:
|
||||
|
||||
```bash
|
||||
mvt-ios summarize ./ios-backup
|
||||
mvt-android summarize ./androidqf-output
|
||||
```
|
||||
|
||||
For a `pipx` installation of MVT, inject the plugin into MVT's environment:
|
||||
|
||||
```bash
|
||||
pipx inject mvt my-mvt-plugin
|
||||
```
|
||||
|
||||
When MVT is installed in an active virtual environment, install the plugin with
|
||||
`pip` in that environment.
|
||||
|
||||
Command 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`.
|
||||
|
||||
## Load a Command File
|
||||
|
||||
For local commands that are not packaged, create a Python file that exports one
|
||||
Click command or group named `cli`:
|
||||
|
||||
```python
|
||||
import click
|
||||
|
||||
|
||||
@click.command("case-summary")
|
||||
@click.argument("path", type=click.Path(exists=True))
|
||||
def cli(path):
|
||||
"""Summarize a case directory."""
|
||||
click.echo(f"Summarizing {path}")
|
||||
```
|
||||
|
||||
Pass the file before the custom command name:
|
||||
|
||||
```bash
|
||||
mvt-ios --load-command ./case_summary.py case-summary ./ios-backup
|
||||
```
|
||||
|
||||
`--load-command` can be repeated and also accepts a folder. MVT loads
|
||||
non-hidden top-level `*.py` files in sorted order and skips `__init__.py`.
|
||||
Every loaded file must export one `cli` object.
|
||||
|
||||
To load a file or folder on every invocation, set the platform-specific
|
||||
environment variable:
|
||||
|
||||
```bash
|
||||
export MVT_IOS_CUSTOM_COMMANDS=./ios_commands
|
||||
export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands
|
||||
```
|
||||
|
||||
## Naming and Errors
|
||||
|
||||
Built-in MVT commands cannot be replaced. External command names must also be
|
||||
unique; when installed packages or environment paths collide, MVT keeps the
|
||||
first command and logs a warning. A collision from an explicit
|
||||
`--load-command` is a usage error.
|
||||
|
||||
A package entry point or environment command that cannot be imported appears
|
||||
as a marked broken command without preventing other MVT commands from working.
|
||||
Invoke that command to see its package or file source and the underlying error.
|
||||
An invalid command supplied explicitly with `--load-command` fails immediately
|
||||
with a usage error.
|
||||
|
||||
Installed command packages use the entry-point name as the CLI command name.
|
||||
The entry point must resolve to a `click.Command` or `click.Group`.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Development
|
||||
|
||||
The Mobile Verification Toolkit team welcomes contributions of new forensic modules or other contributions which help improve the software.
|
||||
|
||||
## Local environment
|
||||
|
||||
MVT uses `uv` for dependency management. To install the project and development dependencies from the locked environment, run:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `ruff` and `mypy`. All can
|
||||
be run automatically with:
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
Run these tests before making new commits or opening pull requests.
|
||||
|
||||
## Module dependencies
|
||||
|
||||
Modules can require other modules to run first by declaring their classes in
|
||||
`dependencies`. The command runner uses a stable topological ordering, so the
|
||||
existing module list order is preserved wherever dependency constraints allow.
|
||||
|
||||
```python
|
||||
class DependentModule(MVTModule):
|
||||
dependencies = (PrerequisiteModule,)
|
||||
|
||||
def run(self):
|
||||
prerequisite_results = self.get_dependency_results(PrerequisiteModule)
|
||||
```
|
||||
|
||||
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
|
||||
warning and does not run any modules.
|
||||
|
||||
## Custom modules
|
||||
|
||||
Module-running `check-*` commands can load custom modules from Python files that
|
||||
are not installed as part of MVT. Load one file with:
|
||||
|
||||
```bash
|
||||
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
|
||||
```
|
||||
|
||||
You can also load a folder. MVT loads non-hidden top-level `*.py` files in
|
||||
sorted order and skips `__init__.py`:
|
||||
|
||||
```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
|
||||
("ios", "check-backup")
|
||||
("ios", "check-fs")
|
||||
("ios", "check-iocs")
|
||||
("ios", "check-sysdiagnose")
|
||||
("android", "check-backup")
|
||||
("android", "check-bugreport")
|
||||
("android", "check-androidqf")
|
||||
("android", "check-intrusion-logs")
|
||||
("android", "check-iocs")
|
||||
```
|
||||
|
||||
Custom modules can depend on existing MVT module classes. Dependencies are
|
||||
resolved with the same ordering logic as built-in modules, and custom modules
|
||||
are appended after built-ins before ordering:
|
||||
|
||||
```python
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.ios.modules.backup.manifest import Manifest
|
||||
|
||||
|
||||
class DependentCustomModule(MVTModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
dependencies = (Manifest,)
|
||||
|
||||
def run(self):
|
||||
manifest_results = self.get_dependency_results(Manifest)
|
||||
self.results = [{"manifest_entries": len(manifest_results)}]
|
||||
```
|
||||
|
||||
## Installed module packages
|
||||
|
||||
Python packages can register modules so they load automatically in every
|
||||
module-running `check-*` command, without `--load-module` or
|
||||
`MVT_CUSTOM_MODULES`. Register an entry point in the `mvt.modules` group in
|
||||
the package's `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[project.entry-points."mvt.modules"]
|
||||
mvt-plugin-amnesty-custom = "mvt_plugin_amnesty_custom:get_modules"
|
||||
```
|
||||
|
||||
The entry point must resolve to an iterable of `MVTModule` subclasses, or to
|
||||
a callable returning one:
|
||||
|
||||
```python
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
class PackagedModule(MVTModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
|
||||
def run(self):
|
||||
self.results = [{"message": "packaged module ran"}]
|
||||
|
||||
|
||||
def get_modules() -> list[type[MVTModule]]:
|
||||
return [PackagedModule]
|
||||
```
|
||||
|
||||
Installed modules follow the same rules as other custom modules: each module
|
||||
must declare `supported_commands`, and dependencies are resolved with the
|
||||
standard ordering logic. A broken entry point is skipped with a warning and
|
||||
does not prevent MVT from running. As with custom commands, installed module
|
||||
packages run as trusted code inside the MVT process, so install only packages
|
||||
from sources you trust.
|
||||
|
||||
For a `pipx` installation of MVT, inject the package into MVT's environment:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
Name module packages `mvt-plugin-<name>` (import package `mvt_plugin_<name>`),
|
||||
and include the name of the publishing organization or author so packages from
|
||||
different groups do not collide: for example, Amnesty International's custom
|
||||
modules would be distributed as `mvt-plugin-amnesty-custom` with the import
|
||||
package `mvt_plugin_amnesty_custom`.
|
||||
|
||||
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
|
||||
technical requirement: modules load through the `mvt.modules` entry point
|
||||
regardless of what the package is called, and existing packages with other
|
||||
names keep working. Note that the prefix is also not a mark of authenticity —
|
||||
anyone can publish a package with any available name, so vet a module package
|
||||
and its publisher before installing it, whatever it is called.
|
||||
|
||||
### Module logging
|
||||
|
||||
Modules log through `self.log`, and MVT names the logger for where the module
|
||||
came from. MVT's own modules log under their dotted path (for example
|
||||
`mvt.ios.modules.mixed.whatsapp`). Everything external is namespaced under
|
||||
`mvt.ext` to keep it visually distinct from built-in modules and isolated from
|
||||
MVT's internal logger tree:
|
||||
|
||||
- Installed packages log under `mvt.ext.<package>`, with the `mvt_plugin_`
|
||||
prefix stripped: modules in `mvt_plugin_amnesty_custom` log as
|
||||
`mvt.ext.amnesty_custom.*`.
|
||||
- Files loaded with `--load-module` or `MVT_CUSTOM_MODULES` log as
|
||||
`mvt.ext.<file name>`.
|
||||
|
||||
## Auditing loaded modules
|
||||
|
||||
Because installed module packages load automatically, MVT records where every
|
||||
module came from:
|
||||
|
||||
- `--list-modules` groups the available modules by source: MVT itself
|
||||
(with its version), each installed package (with its version and, when
|
||||
installed directly from a repository, the commit), and each file loaded
|
||||
with `--load-module` or `MVT_CUSTOM_MODULES` (with the SHA-256 hash of the
|
||||
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
|
||||
the list of modules loaded from it.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
```bash
|
||||
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
||||
```
|
||||
|
||||
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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
|
||||
`mvt.common.plugin_config.plugin_config_path()` 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
|
||||
`mvt.common.plugin_config.plugin_data_folder()` 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.common.plugin_config 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
|
||||
|
||||
|
||||
def sync():
|
||||
settings = ExamplePluginSettings.load()
|
||||
if not settings.API_KEY:
|
||||
raise click.ClickException(
|
||||
"No API key configured. Set MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY or "
|
||||
"run 'example-plugin configure'."
|
||||
)
|
||||
|
||||
settings.LAST_SYNC = datetime.now(timezone.utc).isoformat()
|
||||
settings.save()
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
+15
-1
@@ -31,4 +31,18 @@ Test if the image was created successfully:
|
||||
docker run -it mvt
|
||||
```
|
||||
|
||||
If a prompt is spawned successfully, you can close it with `exit`.
|
||||
If a prompt is spawned successfully, you can close it with `exit`.
|
||||
|
||||
## Access an iOS device from Docker
|
||||
|
||||
On the Linux host, install and start [usbmuxd](https://github.com/libimobiledevice/usbmuxd), then connect and unlock the iOS device. The daemon exposes the device through the `/var/run/usbmuxd` socket.
|
||||
|
||||
Bind that socket into the container to let MVT communicate with the device:
|
||||
|
||||
```bash
|
||||
docker run -it \
|
||||
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
|
||||
ghcr.io/mvt-project/mvt
|
||||
```
|
||||
|
||||
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
|
||||
+4
-3
@@ -37,8 +37,10 @@ export MVT_STIX2="/home/user/IOC1.stix2:/home/user/IOC2.stix2"
|
||||
## Network Access
|
||||
|
||||
When checking URL indicators, MVT follows recognized shortened URLs with an
|
||||
HTTP `HEAD` request. The following environment variables control these
|
||||
requests:
|
||||
HTTP `HEAD` request. URL checks are deduplicated and run concurrently, with at
|
||||
most 20 requests in progress at a time. Redirects within an individual URL
|
||||
chain are still followed sequentially. The following environment variables
|
||||
control these requests:
|
||||
|
||||
- `MVT_NETWORK_ACCESS_ALLOWED` enables or disables network requests. It defaults
|
||||
to `true`. Set it to `false` to prevent MVT from attempting to resolve
|
||||
@@ -73,4 +75,3 @@ You can automaticallly download the latest public indicator files with the comma
|
||||
|
||||
Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs.
|
||||
|
||||
|
||||
+24
-1
@@ -312,6 +312,15 @@ If indicators are provided through the command-line, they are checked against th
|
||||
|
||||
---
|
||||
|
||||
### `urls.json`
|
||||
|
||||
This JSON file collects URLs extracted from SMS, iMessage, and WhatsApp
|
||||
messages. Each entry contains the original URL, its expanded destination when
|
||||
MVT resolved a shortened URL during indicator checking, the message timestamp,
|
||||
and its `sms` or `whatsapp` source.
|
||||
|
||||
---
|
||||
|
||||
### `sms_attachments.json`
|
||||
|
||||
!!! info "Availability"
|
||||
@@ -408,7 +417,21 @@ If indicators are provided through the command-line, they are checked against th
|
||||
Backup: :material-check:
|
||||
Full filesystem dump: :material-check:
|
||||
|
||||
This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*.
|
||||
This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*, along with one record per chat session (marked with `"record_type": "chat_session"`) containing the first and last interaction dates of each conversation. Chat sessions produce `chat_first_message` and `chat_last_message` timeline events, and group chats additionally produce a `group_created` event. A chat session's last-message date can postdate its newest stored message when the most recent messages in the chat were deleted.
|
||||
|
||||
Recent WhatsApp versions key 1:1 chat sessions by an opaque LID identifier rather than the contact's phone number. The module resolves these using the `ZWAPHONENUMBERLIDPAIR` table from the *LID.sqlite* database in the same app group (or from *ChatStorage.sqlite* itself in versions that store it there), populating `partner_resolved_phone_number` on chat session records and using the phone number in timeline events. Each LID-phone number pair is also extracted as a record (`"record_type": "lid_phone_number_pair"`) and produces a `lid_pair_recorded` timeline event marking when WhatsApp learned the association.
|
||||
|
||||
If indicators are provided through the command-line, they are checked against the extracted HTTP links. Any matches are stored in *whatsapp_detected.json*.
|
||||
|
||||
---
|
||||
|
||||
### `whatsapp_contacts.json`
|
||||
|
||||
!!! info "Availability"
|
||||
Backup: :material-check:
|
||||
Full filesystem dump: :material-check:
|
||||
|
||||
This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extracts WhatsApp contact records from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ContactsV2.sqlite*, including each contact's phone number, WhatsApp and LID identifiers, and the per-contact disappearing messages timer, which is not recorded in *ChatStorage.sqlite*. Each timestamp stored on a contact record produces a timeline event: `disappearing_mode_set` (when the disappearing messages timer was last changed), `about_changed` (when the contact last changed their "about" text), `about_expiration` (when a timed "about" is scheduled to expire) and `contact_last_updated` (when the contact record was last updated).
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Check an iOS Sysdiagnose
|
||||
|
||||
`mvt-ios check-sysdiagnose` prepares an iOS sysdiagnose archive for analysis by
|
||||
custom MVT modules. MVT does not include built-in sysdiagnose modules. You must
|
||||
load at least one custom module that explicitly supports this command.
|
||||
|
||||
The command accepts either an extracted sysdiagnose directory or the original
|
||||
gzip-compressed tar archive.
|
||||
|
||||
```bash
|
||||
mvt-ios check-sysdiagnose \
|
||||
--load-module ./sysdiagnose_modules.py \
|
||||
--output ./results \
|
||||
./sysdiagnose_2024.01.02_03-04-05+0200.tar.gz
|
||||
```
|
||||
|
||||
Use `--hashes` to include hashes for analyzed files in `info.json`, and
|
||||
`--list-modules` to display the eligible custom modules without running them.
|
||||
|
||||
## Writing a custom module
|
||||
|
||||
Extend `SysdiagnoseExtraction` to access the archive contents consistently for
|
||||
both directory and tar inputs. Each module must declare the command explicitly
|
||||
in `supported_commands`.
|
||||
|
||||
```python
|
||||
from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
|
||||
|
||||
|
||||
class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
|
||||
supported_commands = (("ios", "check-sysdiagnose"),)
|
||||
slug = "example_sysdiagnose"
|
||||
|
||||
def run(self):
|
||||
paths = self._get_files_by_pattern("*/example.log")
|
||||
if paths:
|
||||
content = self._get_file_content(paths[0]).decode("utf-8", "replace")
|
||||
self.results = [{"content": content}]
|
||||
|
||||
def check_indicators(self):
|
||||
pass
|
||||
|
||||
def serialize(self, result):
|
||||
return None
|
||||
```
|
||||
|
||||
The base class provides `from_sysdiagnose_folder()` and
|
||||
`from_sysdiagnose_tar()` setup hooks, as well as protected file lookup, file
|
||||
reading, and timezone extraction helpers. IPS crash-report metadata is exposed
|
||||
on `ips_files`.
|
||||
+8
-1
@@ -3,6 +3,8 @@ repo_url: https://github.com/mvt-project/mvt
|
||||
edit_uri: edit/main/docs/
|
||||
copyright: Copyright © 2021-2023 MVT Project Developers
|
||||
site_description: Mobile Verification Toolkit Documentation
|
||||
not_in_nav: |
|
||||
android/adb.md
|
||||
markdown_extensions:
|
||||
- attr_list
|
||||
- admonition
|
||||
@@ -28,6 +30,7 @@ nav:
|
||||
- Welcome: "index.md"
|
||||
- Introduction: "introduction.md"
|
||||
- Installation: "install.md"
|
||||
- Command Completion: "command_completion.md"
|
||||
- Using Docker: "docker.md"
|
||||
- MVT for iOS:
|
||||
- iOS Forensic Methodology: "ios/methodology.md"
|
||||
@@ -39,11 +42,15 @@ nav:
|
||||
- Check a Filesystem Dump:
|
||||
- Dumping the filesystem: "ios/filesystem/dump.md"
|
||||
- Check a Filesystem Dump with mvt-ios: "ios/filesystem/check.md"
|
||||
- Check a Sysdiagnose: "ios/sysdiagnose.md"
|
||||
- Records extracted by mvt-ios: "ios/records.md"
|
||||
- MVT for Android:
|
||||
- Android Forensic Methodology: "android/methodology.md"
|
||||
- Check an Android Backup (SMS messages): "android/backup.md"
|
||||
- Check Android Intrusion Logs: "android/intrusion_logs.md"
|
||||
- Indicators of Compromise: "iocs.md"
|
||||
- Development: "development.md"
|
||||
- Development:
|
||||
- Development Instructions: "development/index.md"
|
||||
- Custom CLI Commands: "development/custom_commands.md"
|
||||
- Plugin Configuration: "development/plugin_configuration.md"
|
||||
- License: "license.md"
|
||||
+15
-15
@@ -17,25 +17,25 @@ classifiers = [
|
||||
"Programming Language :: Python",
|
||||
]
|
||||
dependencies = [
|
||||
"click==8.3.2",
|
||||
"rich==14.3.3",
|
||||
"tld==0.13.1",
|
||||
"requests==2.33.1",
|
||||
"simplejson==3.20.2",
|
||||
"packaging==26.0",
|
||||
"click==8.4.2",
|
||||
"rich==15.0.0",
|
||||
"tld==0.13.2",
|
||||
"requests==2.34.2",
|
||||
"simplejson==4.1.1",
|
||||
"packaging==26.3",
|
||||
"appdirs==1.4.4",
|
||||
"iOSbackup==0.9.925",
|
||||
"adb-shell[usb]==0.4.4",
|
||||
"libusb1==3.3.1",
|
||||
"cryptography==47.0.0",
|
||||
"libusb1==3.4.0",
|
||||
"cryptography==50.0.0",
|
||||
"PyYAML>=6.0.2",
|
||||
"pyahocorasick==2.2.0",
|
||||
"betterproto2==0.9.1",
|
||||
"pydantic==2.13.3",
|
||||
"pydantic-settings==2.13.1",
|
||||
"pyahocorasick==2.3.1",
|
||||
"betterproto2==0.10.0",
|
||||
"pydantic==2.13.4",
|
||||
"pydantic-settings==2.15.0",
|
||||
"NSKeyedUnArchiver==1.5.2",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"tzdata==2026.2",
|
||||
"tzdata==2026.3",
|
||||
]
|
||||
requires-python = ">= 3.10"
|
||||
|
||||
@@ -65,9 +65,9 @@ dev = [
|
||||
docs = [
|
||||
"mkdocs==1.6.1",
|
||||
"mkdocs-autorefs==1.4.4",
|
||||
"mkdocs-material==9.7.6",
|
||||
"mkdocs-material==9.7.7",
|
||||
"mkdocs-material-extensions==1.3.1",
|
||||
"mkdocstrings==1.0.4",
|
||||
"mkdocstrings==1.0.6",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -13,6 +13,13 @@ from .artifact import AndroidArtifact
|
||||
class DumpsysADBArtifact(AndroidArtifact):
|
||||
multiline_fields = ["user_keys", "keystore"]
|
||||
|
||||
@staticmethod
|
||||
def is_structural_line(key: str, vals: list) -> bool:
|
||||
if key == "}":
|
||||
return True
|
||||
# XML keystore continuations also split on "=", but never into an identifier.
|
||||
return len(vals) == 2 and key.isidentifier()
|
||||
|
||||
def indented_dump_parser(self, dump_data):
|
||||
"""
|
||||
Parse the indented dumpsys output, generated by DualDumpOutputStream in Android.
|
||||
@@ -41,10 +48,18 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
if key == "":
|
||||
# If the line is empty, it's the terminator for the multiline value
|
||||
in_multiline = False
|
||||
stack.pop()
|
||||
else:
|
||||
if isinstance(stack[-1], list):
|
||||
stack.pop()
|
||||
continue
|
||||
|
||||
if not self.is_structural_line(key, vals):
|
||||
current_dict.append(line.lstrip())
|
||||
continue
|
||||
continue
|
||||
|
||||
in_multiline = False
|
||||
if isinstance(stack[-1], list):
|
||||
stack.pop()
|
||||
current_dict = stack[-1]
|
||||
|
||||
if key == "}":
|
||||
stack.pop()
|
||||
|
||||
@@ -50,13 +50,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
def parse(self, output: str) -> None:
|
||||
daily = None
|
||||
daily_updates: list[dict[str, Any]] = []
|
||||
package_versions: dict[
|
||||
str, str
|
||||
] = {} # Track package versions to detect downgrades
|
||||
records: list[dict[str, Any]] = []
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" Daily from "):
|
||||
if len(daily_updates) > 0:
|
||||
self.results.extend(daily_updates)
|
||||
records.extend(daily_updates)
|
||||
daily_updates = []
|
||||
|
||||
timeframe = line[13:].strip()
|
||||
@@ -89,35 +87,53 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
"vers": vers_nr,
|
||||
}
|
||||
|
||||
# Check for uninstall (version 0)
|
||||
if vers_nr == "0":
|
||||
self.alertstore.medium(
|
||||
f"Detected uninstall of package {package_name} (vers 0)",
|
||||
daily["from"],
|
||||
update_record,
|
||||
)
|
||||
# Check for downgrade
|
||||
elif package_name in package_versions:
|
||||
try:
|
||||
current_vers = int(vers_nr)
|
||||
previous_vers = int(package_versions[package_name])
|
||||
if current_vers < previous_vers:
|
||||
update_record["action"] = "downgrade"
|
||||
update_record["previous_vers"] = str(previous_vers)
|
||||
self.alertstore.medium(
|
||||
f"Detected downgrade of package {package_name} "
|
||||
f"from vers {previous_vers} to vers {current_vers}",
|
||||
daily["from"],
|
||||
update_record,
|
||||
)
|
||||
except ValueError:
|
||||
# If version numbers aren't integers, skip comparison
|
||||
pass
|
||||
|
||||
# Update tracking dictionary
|
||||
package_versions[package_name] = vers_nr
|
||||
|
||||
daily_updates.append(update_record)
|
||||
|
||||
if len(daily_updates) > 0:
|
||||
self.results.extend(daily_updates)
|
||||
records.extend(daily_updates)
|
||||
|
||||
self._detect_uninstalls_and_downgrades(records)
|
||||
self.results.extend(records)
|
||||
|
||||
def _detect_uninstalls_and_downgrades(
|
||||
self, records: list[dict[str, Any]]
|
||||
) -> None:
|
||||
package_versions: dict[str, int] = {}
|
||||
|
||||
for record in sorted(
|
||||
records,
|
||||
key=lambda record: (
|
||||
record["from"],
|
||||
record["to"],
|
||||
record["package_name"],
|
||||
),
|
||||
):
|
||||
package_name = record["package_name"]
|
||||
vers_nr = record["vers"]
|
||||
|
||||
if vers_nr == "0":
|
||||
self.alertstore.medium(
|
||||
f"Detected uninstall of package {package_name} (vers 0)",
|
||||
record["from"],
|
||||
record,
|
||||
)
|
||||
package_versions.pop(package_name, None)
|
||||
continue
|
||||
|
||||
try:
|
||||
current_vers = int(vers_nr)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
previous_vers = package_versions.get(package_name)
|
||||
if previous_vers is not None and current_vers < previous_vers:
|
||||
record["action"] = "downgrade"
|
||||
record["previous_vers"] = str(previous_vers)
|
||||
self.alertstore.medium(
|
||||
f"Detected downgrade of package {package_name} "
|
||||
f"from vers {previous_vers} to vers {current_vers}",
|
||||
record["from"],
|
||||
record,
|
||||
)
|
||||
|
||||
package_versions[package_name] = current_vers
|
||||
@@ -31,21 +31,38 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact):
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
time_elapsed = line.strip().split(" ", 1)[0]
|
||||
time_parts = line.strip().split()
|
||||
time_elapsed = time_parts[0]
|
||||
if (
|
||||
len(time_parts) > 1
|
||||
and len(time_parts[0]) == 5
|
||||
and time_parts[0][2] == "-"
|
||||
and ":" in time_parts[1]
|
||||
):
|
||||
time_elapsed = " ".join(time_parts[:2])
|
||||
|
||||
event = ""
|
||||
if line.find("+job") > 0:
|
||||
event = "start_job"
|
||||
uid = line[line.find("+job") + 5 : line.find(":")]
|
||||
service = line[line.find(":") + 1 :].strip('"')
|
||||
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"
|
||||
uid = line[line.find("-job") + 5 : line.find(":")]
|
||||
service = line[line.find(":") + 1 :].strip('"')
|
||||
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:
|
||||
uid = line[line.find("+running +wake_lock=") + 21 : line.find(":")]
|
||||
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()
|
||||
|
||||
@@ -29,10 +29,9 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
rxp = re.compile(
|
||||
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\].*\[Pid:\((\d+)\)\](\w+).*sql\=\"(.+?)\""
|
||||
) # pylint: disable=line-too-long
|
||||
rxp_no_pid = re.compile(
|
||||
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\][ ]{1}(\w+).*sql\=\"(.+?)\""
|
||||
r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} "
|
||||
r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*"
|
||||
r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\""
|
||||
) # pylint: disable=line-too-long
|
||||
|
||||
pool = None
|
||||
@@ -56,29 +55,16 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
pool = None
|
||||
continue
|
||||
|
||||
matches = rxp.findall(line)
|
||||
if not matches:
|
||||
matches = rxp_no_pid.findall(line)
|
||||
if not matches:
|
||||
continue
|
||||
match = rxp.match(line)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
match = matches[0]
|
||||
self.results.append(
|
||||
{
|
||||
"isodate": match[0],
|
||||
"action": match[1],
|
||||
"sql": match[2],
|
||||
"path": pool,
|
||||
}
|
||||
)
|
||||
else:
|
||||
match = matches[0]
|
||||
self.results.append(
|
||||
{
|
||||
"isodate": match[0],
|
||||
"pid": match[1],
|
||||
"action": match[2],
|
||||
"sql": match[3],
|
||||
"path": pool,
|
||||
}
|
||||
)
|
||||
result = {
|
||||
"isodate": match.group(1),
|
||||
"action": match.group(3),
|
||||
"sql": match.group(4),
|
||||
"path": pool,
|
||||
}
|
||||
if match.group(2):
|
||||
result["pid"] = match.group(2)
|
||||
self.results.append(result)
|
||||
@@ -4,7 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from mvt.android.utils import ROOT_PACKAGES
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
@@ -71,6 +71,8 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
"timestamp": "",
|
||||
"first_install_time": "",
|
||||
"last_update_time": "",
|
||||
"installer": "",
|
||||
"system": False,
|
||||
"permissions": list(),
|
||||
"requested_permissions": list(),
|
||||
}
|
||||
@@ -78,7 +80,14 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
in_runtime_permissions = False
|
||||
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():
|
||||
user_match = re.match(r"User (\d+):", line.strip())
|
||||
if user_match:
|
||||
current_user = int(user_match.group(1))
|
||||
|
||||
if in_install_permissions:
|
||||
if line.startswith(" " * 4) and not line.startswith(" " * 6):
|
||||
in_install_permissions = False
|
||||
@@ -102,7 +111,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
if "granted=" in lineinfo[1]:
|
||||
granted = "granted=true" in lineinfo[1]
|
||||
|
||||
details["permissions"].append(
|
||||
runtime_permissions.setdefault(current_user, []).append(
|
||||
{"name": permission, "granted": granted, "type": "runtime"}
|
||||
)
|
||||
if in_declared_permissions:
|
||||
@@ -126,8 +135,12 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
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="):
|
||||
details["first_install_time"] = line.split("=")[1].strip()
|
||||
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:":
|
||||
@@ -139,6 +152,19 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
elif line.strip() == "requested permissions:":
|
||||
in_requested_permissions = True
|
||||
|
||||
if 0 in first_install_times:
|
||||
details["first_install_time"] = first_install_times[0]
|
||||
elif None in first_install_times:
|
||||
details["first_install_time"] = first_install_times[None]
|
||||
elif first_install_times:
|
||||
details["first_install_time"] = next(iter(first_install_times.values()))
|
||||
|
||||
if 0 in runtime_permissions:
|
||||
details["permissions"].extend(runtime_permissions[0])
|
||||
elif None in runtime_permissions:
|
||||
details["permissions"].extend(runtime_permissions[None])
|
||||
elif runtime_permissions:
|
||||
details["permissions"].extend(next(iter(runtime_permissions.values())))
|
||||
return details
|
||||
|
||||
def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -96,6 +96,18 @@ class DumpsysReceiversArtifact(AndroidArtifact):
|
||||
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
|
||||
@@ -109,7 +121,7 @@ class DumpsysReceiversArtifact(AndroidArtifact):
|
||||
|
||||
# If we got this far, we are processing receivers for the
|
||||
# activities we are interested in.
|
||||
receiver = line.strip().split(" ")[1]
|
||||
receiver = parts[1]
|
||||
package_name = receiver.split("/")[0]
|
||||
|
||||
self.results[intent].append(
|
||||
|
||||
@@ -34,6 +34,7 @@ TOMBSTONE_TEXT_KEY_MAPPINGS = {
|
||||
"signal": "signal_info",
|
||||
"code": "code",
|
||||
"Cause": "cause",
|
||||
"Abort message": "abort_message",
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +68,8 @@ class TombstoneCrashResult(pydantic.BaseModel):
|
||||
uid: int
|
||||
signal_info: SignalInfo
|
||||
cause: Optional[str] = None
|
||||
causes: Optional[List[dict]] = None
|
||||
abort_message: Optional[str] = None
|
||||
extra: Optional[str] = None
|
||||
|
||||
|
||||
@@ -206,6 +209,11 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
return True
|
||||
|
||||
def _load_pid_line(self, line: str, tombstone: dict) -> bool:
|
||||
# The first pid line identifies the crashing thread. Full text tombstones
|
||||
# contain additional pid lines for the other threads in the process.
|
||||
if "pid" in tombstone:
|
||||
return True
|
||||
|
||||
try:
|
||||
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
|
||||
process_info = parts[0]
|
||||
|
||||
+144
-7
@@ -4,10 +4,23 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from zipfile import BadZipFile
|
||||
|
||||
import click
|
||||
|
||||
from mvt.common.cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
load_cli_commands_option,
|
||||
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 (
|
||||
HELP_MSG_ANDROID_BACKUP_PASSWORD,
|
||||
HELP_MSG_CHECK_ADB_REMOVED,
|
||||
@@ -17,19 +30,24 @@ from mvt.common.help import (
|
||||
HELP_MSG_CHECK_BUGREPORT,
|
||||
HELP_MSG_CHECK_IOCS,
|
||||
HELP_MSG_CHECK_INTRUSION_LOGS,
|
||||
HELP_MSG_DELAY_CHECKS,
|
||||
HELP_MSG_COMPLETION,
|
||||
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
HELP_MSG_DISABLE_UPDATE_CHECK,
|
||||
HELP_MSG_HASHES,
|
||||
HELP_MSG_IOC,
|
||||
HELP_MSG_LIST_MODULES,
|
||||
HELP_MSG_LOAD_MODULE,
|
||||
HELP_MSG_MODULE,
|
||||
HELP_MSG_NONINTERACTIVE,
|
||||
HELP_MSG_OUTPUT,
|
||||
HELP_MSG_STIX2,
|
||||
HELP_MSG_VERBOSE,
|
||||
HELP_MSG_VERSION,
|
||||
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
|
||||
|
||||
@@ -59,10 +77,18 @@ def _get_disable_flags(ctx):
|
||||
)
|
||||
|
||||
|
||||
def _load_custom_modules(load_module):
|
||||
try:
|
||||
return load_custom_modules(load_module)
|
||||
except CustomModuleLoadError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Main
|
||||
# ==============================================================================
|
||||
@click.group(invoke_without_command=False)
|
||||
@load_cli_commands_option
|
||||
@click.option(
|
||||
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
|
||||
)
|
||||
@@ -76,10 +102,11 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["disable_version_check"] = disable_update_check
|
||||
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
if ctx.invoked_subcommand != "completion":
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -90,6 +117,40 @@ def version():
|
||||
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)
|
||||
# ==============================================================================
|
||||
@@ -119,11 +180,28 @@ def check_adb(ctx):
|
||||
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_path):
|
||||
def check_bugreport(
|
||||
ctx,
|
||||
iocs,
|
||||
output,
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
verbose,
|
||||
bugreport_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
# Always generate hashes as bug reports are small.
|
||||
cmd = CmdAndroidCheckBugreport(
|
||||
target_path=bugreport_path,
|
||||
@@ -133,6 +211,7 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
|
||||
hashes=True,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -141,7 +220,10 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
|
||||
|
||||
log.info("Checking Android bug report at path: %s", bugreport_path)
|
||||
|
||||
cmd.run()
|
||||
try:
|
||||
cmd.run()
|
||||
except BadZipFile as exc:
|
||||
raise click.ClickException(f"Invalid bugreport archive: {exc}") from exc
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
@@ -164,6 +246,13 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
|
||||
)
|
||||
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@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("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@@ -174,12 +263,14 @@ def check_backup(
|
||||
iocs,
|
||||
output,
|
||||
list_modules,
|
||||
load_module,
|
||||
non_interactive,
|
||||
backup_password,
|
||||
verbose,
|
||||
backup_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
# Always generate hashes as backups are generally small.
|
||||
cmd = CmdAndroidCheckBackup(
|
||||
@@ -193,6 +284,7 @@ def check_backup(
|
||||
},
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -223,7 +315,18 @@ def check_backup(
|
||||
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.option("--virustotal", "-V", is_flag=True, help=HELP_MSG_VIRUS_TOTAL)
|
||||
@click.option(
|
||||
"--delay", "-d", type=click.IntRange(min=0), default=16, help=HELP_MSG_DELAY_CHECKS
|
||||
)
|
||||
@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("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@@ -235,13 +338,17 @@ def check_androidqf(
|
||||
output,
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
hashes,
|
||||
virustotal,
|
||||
delay,
|
||||
non_interactive,
|
||||
backup_password,
|
||||
verbose,
|
||||
androidqf_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
cmd = CmdAndroidCheckAndroidQF(
|
||||
target_path=androidqf_path,
|
||||
@@ -252,9 +359,12 @@ def check_androidqf(
|
||||
module_options={
|
||||
"interactive": not non_interactive,
|
||||
"backup_password": cli_load_android_backup_password(log, backup_password),
|
||||
"virustotal": virustotal,
|
||||
"virustotal_delay": delay,
|
||||
},
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -288,6 +398,13 @@ def check_androidqf(
|
||||
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option(
|
||||
"--timezone",
|
||||
"-t",
|
||||
@@ -307,11 +424,13 @@ def check_intrusion_logs(
|
||||
output,
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
timezone,
|
||||
verbose,
|
||||
logs_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
module_options = {}
|
||||
if timezone:
|
||||
@@ -325,6 +444,7 @@ def check_intrusion_logs(
|
||||
module_options=module_options if module_options else None,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -352,15 +472,25 @@ def check_intrusion_logs(
|
||||
)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.argument("FOLDER", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_iocs(ctx, iocs, list_modules, module, folder):
|
||||
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdCheckIOCS(
|
||||
target_path=folder,
|
||||
ioc_files=iocs,
|
||||
module_name=module,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
platform="android",
|
||||
)
|
||||
cmd.modules = (
|
||||
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
|
||||
@@ -382,3 +512,10 @@ def check_iocs(ctx, iocs, list_modules, module, folder):
|
||||
def download_indicators():
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
ioc_updates.update()
|
||||
|
||||
|
||||
register_cli_plugins(
|
||||
cli,
|
||||
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
@@ -3,6 +3,7 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -13,10 +14,11 @@ from typing import List, Optional
|
||||
|
||||
from mvt.android.artifacts.getprop import GetProp
|
||||
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
||||
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup
|
||||
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup, InvalidAndroidBackup
|
||||
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.androidqf import ANDROIDQF_MODULES
|
||||
from .modules.androidqf.base import AndroidQFModule
|
||||
@@ -50,6 +52,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -64,8 +67,10 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
self.platform = "android"
|
||||
self.name = "check-androidqf"
|
||||
self.modules = ANDROIDQF_MODULES
|
||||
|
||||
@@ -90,6 +95,47 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
self.__zip = zipfile.ZipFile(self.target_path)
|
||||
self.__files = self.__zip.namelist()
|
||||
|
||||
self._load_acquisition_context()
|
||||
|
||||
def _load_acquisition_context(self) -> None:
|
||||
"""Pass AndroidQF acquisition metadata to nested commands and modules."""
|
||||
context = {}
|
||||
metadata_files = [
|
||||
file_path
|
||||
for file_path in self.__files
|
||||
if file_path.replace("\\", "/").rsplit("/", 1)[-1] == "acquisition.json"
|
||||
]
|
||||
for file_path in metadata_files:
|
||||
try:
|
||||
metadata = json.loads(self._get_file_content(file_path))
|
||||
if isinstance(metadata, dict):
|
||||
context["started"] = metadata.get("started")
|
||||
context["adb_host_public_key"] = metadata.get("adb_host_public_key")
|
||||
break
|
||||
except (json.JSONDecodeError, OSError, TypeError, UnicodeDecodeError):
|
||||
self.log.warning(
|
||||
'Unable to read AndroidQF acquisition metadata "%s"', file_path
|
||||
)
|
||||
|
||||
if not context.get("adb_host_public_key"):
|
||||
key_files = [
|
||||
file_path
|
||||
for file_path in self.__files
|
||||
if file_path.replace("\\", "/").rsplit("/", 1)[-1] == "adb_host_key.pub"
|
||||
]
|
||||
for file_path in key_files:
|
||||
try:
|
||||
context["adb_host_public_key"] = self._get_file_content(
|
||||
file_path
|
||||
).decode("utf-8")
|
||||
break
|
||||
except (OSError, UnicodeDecodeError):
|
||||
self.log.warning(
|
||||
'Unable to read AndroidQF ADB host key "%s"', file_path
|
||||
)
|
||||
|
||||
self.module_options["androidqf_acquisition"] = context
|
||||
|
||||
def module_init(self, module: AndroidQFModule) -> None: # type: ignore[override]
|
||||
if self.__format == "zip" and self.__zip:
|
||||
module.from_zip(self.__zip, self.__files)
|
||||
@@ -210,11 +256,13 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
module_options=self.module_options,
|
||||
hashes=self.hashes,
|
||||
sub_command=True,
|
||||
custom_modules=self.custom_modules,
|
||||
)
|
||||
cmd.from_zip(bugreport)
|
||||
cmd.run()
|
||||
|
||||
self.timeline.extend(cmd.timeline)
|
||||
self.url_results.extend(cmd.url_results)
|
||||
self.alertstore.extend(cmd.alertstore.alerts)
|
||||
finally:
|
||||
if bugreport:
|
||||
@@ -239,11 +287,20 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
module_options=self.module_options,
|
||||
hashes=self.hashes,
|
||||
sub_command=True,
|
||||
custom_modules=self.custom_modules,
|
||||
)
|
||||
cmd.from_ab(backup)
|
||||
try:
|
||||
cmd.from_ab(backup)
|
||||
except InvalidAndroidBackup as exc:
|
||||
self.log.warning(
|
||||
"Skipping backup modules as backup.ab is malformed: %s", exc
|
||||
)
|
||||
return False
|
||||
|
||||
cmd.run()
|
||||
|
||||
self.timeline.extend(cmd.timeline)
|
||||
self.url_results.extend(cmd.url_results)
|
||||
self.alertstore.extend(cmd.alertstore.alerts)
|
||||
return True
|
||||
|
||||
@@ -283,6 +340,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
|
||||
elif self.__format == "zip" and self.__zip:
|
||||
temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_")
|
||||
temp_root = Path(temp_dir).resolve()
|
||||
for entry in intrusion_log_files:
|
||||
normalized = entry.replace("\\", "/")
|
||||
idx = normalized.find("intrusion_logs/")
|
||||
@@ -290,9 +348,15 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
if not relative or relative.endswith("/"):
|
||||
continue
|
||||
|
||||
target = os.path.join(temp_dir, relative)
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with self.__zip.open(entry) as src, open(target, "wb") as dst:
|
||||
target = (temp_root / relative).resolve()
|
||||
if not target.is_relative_to(temp_root):
|
||||
self.log.warning(
|
||||
"Skipping unsafe intrusion log archive entry: %s", entry
|
||||
)
|
||||
continue
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.__zip.open(entry) as src, target.open("wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
intrusion_logs_path = temp_dir
|
||||
@@ -311,10 +375,12 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
module_options=adv_module_options,
|
||||
hashes=self.hashes,
|
||||
sub_command=True,
|
||||
custom_modules=self.custom_modules,
|
||||
)
|
||||
cmd.run()
|
||||
|
||||
self.timeline.extend(cmd.timeline)
|
||||
self.url_results.extend(cmd.url_results)
|
||||
self.alertstore.extend(cmd.alertstore.alerts)
|
||||
return True
|
||||
|
||||
|
||||
@@ -21,12 +21,17 @@ from mvt.android.parsers.backup import (
|
||||
)
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidAndroidBackup(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CmdAndroidCheckBackup(Command):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -41,6 +46,7 @@ class CmdAndroidCheckBackup(Command):
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -55,8 +61,10 @@ class CmdAndroidCheckBackup(Command):
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
self.platform = "android"
|
||||
self.name = "check-backup"
|
||||
self.modules = BACKUP_MODULES
|
||||
|
||||
@@ -68,6 +76,10 @@ class CmdAndroidCheckBackup(Command):
|
||||
self.__type = "ab"
|
||||
header = parse_ab_header(ab_file_bytes)
|
||||
if not header["backup"]:
|
||||
if self.sub_command:
|
||||
raise InvalidAndroidBackup(
|
||||
"Invalid backup format, file should be in .ab format"
|
||||
)
|
||||
log.critical("Invalid backup format, file should be in .ab format")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -83,12 +95,25 @@ class CmdAndroidCheckBackup(Command):
|
||||
log.critical("Invalid backup password")
|
||||
sys.exit(1)
|
||||
except AndroidBackupParsingError as exc:
|
||||
if self.sub_command:
|
||||
raise InvalidAndroidBackup(
|
||||
f"Impossible to parse this backup file: {exc}"
|
||||
) from exc
|
||||
log.critical("Impossible to parse this backup file: %s", exc)
|
||||
log.critical("Please use Android Backup Extractor (ABE) instead")
|
||||
sys.exit(1)
|
||||
|
||||
dbytes = io.BytesIO(tardata)
|
||||
self.__tar = tarfile.open(fileobj=dbytes)
|
||||
try:
|
||||
self.__tar = tarfile.open(fileobj=dbytes)
|
||||
except tarfile.TarError as exc:
|
||||
if self.sub_command:
|
||||
raise InvalidAndroidBackup(
|
||||
f"Impossible to parse this backup file: {exc}"
|
||||
) from exc
|
||||
log.critical("Impossible to parse this backup file: %s", exc)
|
||||
log.critical("Please use Android Backup Extractor (ABE) instead")
|
||||
sys.exit(1)
|
||||
for member in self.__tar:
|
||||
self.__files.append(member.name)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from zipfile import ZipFile
|
||||
from mvt.android.modules.bugreport.base import BugReportModule
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.bugreport import BUGREPORT_MODULES
|
||||
|
||||
@@ -32,6 +33,7 @@ class CmdAndroidCheckBugreport(Command):
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -46,8 +48,10 @@ class CmdAndroidCheckBugreport(Command):
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
self.platform = "android"
|
||||
self.name = "check-bugreport"
|
||||
self.modules = BUGREPORT_MODULES
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Optional
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.intrusion_logs import (
|
||||
INTRUSION_LOGS_MODULES,
|
||||
@@ -35,6 +36,7 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -49,8 +51,10 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
self.platform = "android"
|
||||
self.name = "check-intrusion-logs"
|
||||
self.modules = INTRUSION_LOGS_MODULES
|
||||
self._all_events: dict[str, list[dict]] = {}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
from .aqf_files import AQFFiles
|
||||
from .aqf_getprop import AQFGetProp
|
||||
from .aqf_log_timestamps import AQFLogTimestamps
|
||||
from .aqf_packages import AQFPackages
|
||||
from .aqf_processes import AQFProcesses
|
||||
from .aqf_settings import AQFSettings
|
||||
from .mounts import Mounts
|
||||
from .root_binaries import RootBinaries
|
||||
from .sms import SMS
|
||||
|
||||
ANDROIDQF_MODULES = [
|
||||
AQFPackages,
|
||||
@@ -18,7 +18,7 @@ ANDROIDQF_MODULES = [
|
||||
AQFGetProp,
|
||||
AQFSettings,
|
||||
AQFFiles,
|
||||
SMS,
|
||||
AQFLogTimestamps,
|
||||
RootBinaries,
|
||||
Mounts,
|
||||
]
|
||||
@@ -5,8 +5,11 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from rich.progress import track
|
||||
|
||||
from mvt.android.utils import (
|
||||
BROWSER_INSTALLERS,
|
||||
PLAY_STORE_INSTALLERS,
|
||||
@@ -15,7 +18,8 @@ from mvt.android.utils import (
|
||||
SYSTEM_UPDATE_PACKAGES,
|
||||
THIRD_PARTY_STORE_INSTALLERS,
|
||||
)
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleResults
|
||||
from mvt.common.virustotal import VTNoKey, VTQuotaExceeded, virustotal_lookup
|
||||
|
||||
from .base import AndroidQFModule
|
||||
|
||||
@@ -125,6 +129,65 @@ class AQFPackages(AndroidQFModule):
|
||||
)
|
||||
break
|
||||
|
||||
if self.module_options.get("virustotal", False):
|
||||
self.check_virustotal(
|
||||
delay=self.module_options.get("virustotal_delay", 0)
|
||||
)
|
||||
|
||||
def check_virustotal(self, delay: int = 0) -> None:
|
||||
files_by_hash: dict[
|
||||
str, list[tuple[ModuleAtomicResult, ModuleAtomicResult]]
|
||||
] = {}
|
||||
for package in self.results:
|
||||
if package.get("system", False):
|
||||
continue
|
||||
|
||||
for package_file in package.get("files", []):
|
||||
file_hash = package_file.get("sha256")
|
||||
if not file_hash:
|
||||
continue
|
||||
|
||||
files_by_hash.setdefault(file_hash, []).append((package, package_file))
|
||||
|
||||
total_hashes = len(files_by_hash)
|
||||
if total_hashes == 0:
|
||||
return
|
||||
|
||||
progress_desc = f"Looking up {total_hashes} package files on VirusTotal..."
|
||||
for index, file_hash in enumerate(
|
||||
track(files_by_hash, description=progress_desc)
|
||||
):
|
||||
try:
|
||||
results = virustotal_lookup(file_hash)
|
||||
except VTNoKey as exc:
|
||||
self.log.warning("%s", exc)
|
||||
return
|
||||
except VTQuotaExceeded as exc:
|
||||
self.log.warning("Unable to continue VirusTotal lookups: %s", exc)
|
||||
break
|
||||
|
||||
if index < total_hashes - 1 and delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
if not results:
|
||||
continue
|
||||
|
||||
attributes = results.get("attributes", {})
|
||||
stats = attributes.get("last_analysis_stats", {})
|
||||
positives = stats.get("malicious", 0)
|
||||
total = len(attributes.get("last_analysis_results", {}))
|
||||
detection = f"{positives}/{total}"
|
||||
|
||||
for package, package_file in files_by_hash[file_hash]:
|
||||
package_file["virustotal"] = detection
|
||||
if positives > 0:
|
||||
self.alertstore.high(
|
||||
f'VirusTotal flagged package "{package["name"]}" file '
|
||||
f'"{package_file["path"]}" with {detection} detections',
|
||||
"",
|
||||
package,
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
packages = self._get_files_by_pattern("*/packages.json")
|
||||
if not packages:
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
|
||||
from mvt.android.parsers.backup import (
|
||||
AndroidBackupParsingError,
|
||||
InvalidBackupPassword,
|
||||
parse_ab_header,
|
||||
parse_backup_file,
|
||||
parse_tar_for_sms,
|
||||
)
|
||||
|
||||
from .base import AndroidQFModule
|
||||
|
||||
|
||||
class SMS(AndroidQFModule):
|
||||
"""
|
||||
This module analyse SMS file in backup
|
||||
"""
|
||||
|
||||
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[list] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for message in self.results:
|
||||
if "body" not in message:
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_domains(message.get("links", []))
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
def parse_backup(self, data):
|
||||
header = parse_ab_header(data)
|
||||
if not header["backup"]:
|
||||
self.log.critical("Invalid backup format, backup.ab was not analysed")
|
||||
return
|
||||
|
||||
password = None
|
||||
if header["encryption"] != "none":
|
||||
password = prompt_or_load_android_backup_password(
|
||||
self.log, self.module_options
|
||||
)
|
||||
if not password:
|
||||
self.log.critical("No backup password provided.")
|
||||
return
|
||||
|
||||
try:
|
||||
tardata = parse_backup_file(data, password=password)
|
||||
except InvalidBackupPassword:
|
||||
self.log.critical("Invalid backup password")
|
||||
return
|
||||
except AndroidBackupParsingError:
|
||||
self.log.critical(
|
||||
"Impossible to parse this backup file, please use"
|
||||
" Android Backup Extractor instead"
|
||||
)
|
||||
return
|
||||
|
||||
if not tardata:
|
||||
return
|
||||
|
||||
try:
|
||||
self.results = parse_tar_for_sms(tardata)
|
||||
except AndroidBackupParsingError:
|
||||
self.log.info(
|
||||
"Impossible to read SMS from the Android Backup, "
|
||||
"please extract the SMS and try extracting it with "
|
||||
"Android Backup Extractor"
|
||||
)
|
||||
return
|
||||
|
||||
def run(self) -> None:
|
||||
files = self._get_files_by_pattern("*/backup.ab")
|
||||
if not files:
|
||||
self.log.info("No backup data found")
|
||||
return
|
||||
|
||||
self.parse_backup(self._get_file_content(files[0]))
|
||||
self.log.info("Identified %d SMS in backup data", len(self.results))
|
||||
@@ -4,9 +4,8 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from mvt.common.config import settings
|
||||
from mvt.common.password import prompt_password
|
||||
|
||||
MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD"
|
||||
|
||||
@@ -49,7 +48,7 @@ def prompt_or_load_android_backup_password(log, module_options):
|
||||
|
||||
# The default is to allow interactivity
|
||||
elif module_options.get("interactive", True):
|
||||
backup_password = Prompt.ask(prompt="Enter backup password", password=True)
|
||||
backup_password = prompt_password("Enter backup password: ")
|
||||
else:
|
||||
log.critical(
|
||||
"Cannot decrypt backup because interactivity"
|
||||
|
||||
@@ -36,6 +36,8 @@ class SMS(BackupModule):
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
messages = []
|
||||
url_batches = []
|
||||
for message in self.results:
|
||||
if "body" not in message:
|
||||
continue
|
||||
@@ -44,12 +46,21 @@ class SMS(BackupModule):
|
||||
if message_links == []:
|
||||
message_links = check_for_links(message.get("text", ""))
|
||||
|
||||
ioc_match = self.indicators.check_urls(message_links)
|
||||
messages.append(message)
|
||||
url_batches.append(message_links)
|
||||
|
||||
for message, ioc_match in zip(
|
||||
messages, self.indicators.check_url_batches(url_batches)
|
||||
):
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
continue
|
||||
|
||||
def collect_url_results(self) -> None:
|
||||
for message in self.results:
|
||||
for url in message.get("links", []):
|
||||
self.add_url_result(url, message.get("isodate"), "sms")
|
||||
|
||||
def run(self) -> None:
|
||||
sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup"
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
@@ -53,3 +57,122 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
|
||||
"Identified a total of %d trusted ADB keys",
|
||||
len(self.results[0].get("user_keys", [])),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _key_material(public_key: object) -> str:
|
||||
if isinstance(public_key, bytes):
|
||||
public_key = public_key.decode("utf-8", errors="replace")
|
||||
if not isinstance(public_key, str):
|
||||
return ""
|
||||
return public_key.strip().split(" ", 1)[0]
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_key(public_key: str) -> bool:
|
||||
if not public_key:
|
||||
return False
|
||||
try:
|
||||
return bool(base64.b64decode(public_key, validate=True))
|
||||
except (binascii.Error, ValueError):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_acquisition_time(value: object) -> Optional[datetime.datetime]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
timestamp = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if not timestamp.tzinfo:
|
||||
timestamp = timestamp.replace(tzinfo=datetime.timezone.utc)
|
||||
return timestamp.astimezone(datetime.timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _parse_last_connected(value: object) -> Optional[datetime.datetime]:
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(
|
||||
int(str(value)) / 1000,
|
||||
tz=datetime.timezone.utc,
|
||||
)
|
||||
except (OSError, OverflowError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _trusted_keys(self) -> list[dict]:
|
||||
"""Return unique trusted keys, preferring keystore connection metadata."""
|
||||
trusted_keys = []
|
||||
seen = set()
|
||||
for result in self.results:
|
||||
keystore = result.get("keystore", [])
|
||||
candidates = keystore if isinstance(keystore, list) else []
|
||||
candidates = [*candidates, *result.get("user_keys", [])]
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
key = self._key_material(candidate.get("key"))
|
||||
identity = key or repr(candidate)
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
trusted_keys.append(candidate)
|
||||
return trusted_keys
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if "androidqf_acquisition" not in self.module_options:
|
||||
return super().check_indicators()
|
||||
|
||||
context = self.module_options.get("androidqf_acquisition")
|
||||
if not isinstance(context, dict):
|
||||
context = {}
|
||||
acquisition_key = self._key_material(context.get("adb_host_public_key"))
|
||||
if acquisition_key and not self._is_valid_key(acquisition_key):
|
||||
acquisition_key = ""
|
||||
acquisition_time = self._parse_acquisition_time(context.get("started"))
|
||||
cutoff = (
|
||||
acquisition_time - datetime.timedelta(days=1) if acquisition_time else None
|
||||
)
|
||||
|
||||
for trusted_key in self._trusted_keys():
|
||||
key = self._key_material(trusted_key.get("key"))
|
||||
fingerprint = trusted_key.get("fingerprint") or "<unknown key>"
|
||||
user = trusted_key.get("user") or "unknown user"
|
||||
description = f"{fingerprint} ({user})"
|
||||
last_connected = self._parse_last_connected(
|
||||
trusted_key.get("last_connected")
|
||||
)
|
||||
event_time = (
|
||||
convert_datetime_to_iso(last_connected) if last_connected else ""
|
||||
)
|
||||
|
||||
if not self._is_valid_key(key):
|
||||
self.alertstore.low(
|
||||
f"Found an invalid trusted ADB host key: {description}",
|
||||
event_time,
|
||||
trusted_key,
|
||||
)
|
||||
continue
|
||||
|
||||
if not acquisition_key:
|
||||
self.alertstore.low(
|
||||
"Found a trusted ADB host key, but the AndroidQF acquisition "
|
||||
f"does not include its host key: {description}",
|
||||
event_time,
|
||||
trusted_key,
|
||||
)
|
||||
continue
|
||||
|
||||
if key != acquisition_key:
|
||||
self.alertstore.low(
|
||||
"Found a trusted ADB host key different from the AndroidQF "
|
||||
f"acquisition host: {description}",
|
||||
event_time,
|
||||
trusted_key,
|
||||
)
|
||||
continue
|
||||
|
||||
if cutoff and last_connected and last_connected <= cutoff:
|
||||
self.alertstore.info(
|
||||
"Found a trusted ADB host key last connected at least one day "
|
||||
f"before the AndroidQF acquisition: {description}",
|
||||
event_time,
|
||||
trusted_key,
|
||||
)
|
||||
@@ -35,24 +35,6 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
|
||||
self.results = results if results else {}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for result in self.results:
|
||||
if self.indicators:
|
||||
receiver_name = self.results[result][0]["receiver"]
|
||||
|
||||
# return IoC if the stix2 process name a substring of the receiver name
|
||||
ioc_match = self.indicators.check_receiver_prefix(receiver_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
self.results[result][0],
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
|
||||
@@ -264,11 +264,17 @@ SECURITY_EVENT_TAGS = {
|
||||
}
|
||||
|
||||
SECURITY_EVENT_METADATA_KEYS = {
|
||||
"event_id",
|
||||
"event_time",
|
||||
"event_type",
|
||||
"timestamp",
|
||||
}
|
||||
|
||||
# Known platform failures that remain in the timeline but do not need a warning.
|
||||
KNOWN_BENIGN_KEY_GENERATION_FAILURES = {
|
||||
("PinStorage_crossReboot_key", 1001),
|
||||
}
|
||||
|
||||
|
||||
class SecurityEvent(IntrusionLogsModule):
|
||||
"""This module analyzes security events from intrusion logs."""
|
||||
@@ -302,10 +308,15 @@ class SecurityEvent(IntrusionLogsModule):
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
"""Check security events against indicators of compromise."""
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
# Heuristic alerts are intrinsic to the event, so they run even
|
||||
# when no indicator set is loaded.
|
||||
self._check_security_heuristics(result)
|
||||
|
||||
# The remaining checks match events against loaded indicators.
|
||||
if not self.indicators:
|
||||
continue
|
||||
|
||||
# Check app process start events for suspicious package names
|
||||
if "app_process_start" in result:
|
||||
process_info = result["app_process_start"]
|
||||
@@ -389,60 +400,80 @@ class SecurityEvent(IntrusionLogsModule):
|
||||
matched_indicator=ioc.ioc,
|
||||
)
|
||||
|
||||
# Flag failed cryptographic operations as potentially suspicious
|
||||
if "key_generated" in result:
|
||||
if not result["key_generated"].get("success", True):
|
||||
self.log.warning(
|
||||
"Failed key generation detected for key_id: %s",
|
||||
result["key_generated"].get("key_id", "unknown"),
|
||||
)
|
||||
|
||||
# Flag certificate validation failures
|
||||
if "cert_validation_failure" in result:
|
||||
def _check_security_heuristics(self, result: dict) -> None:
|
||||
"""Raise alerts for events that are intrinsically suspicious,
|
||||
independent of any loaded indicators."""
|
||||
# Flag failed cryptographic operations as potentially suspicious
|
||||
if "key_generated" in result:
|
||||
key_info = result["key_generated"]
|
||||
key_id = key_info.get("key_id", "unknown")
|
||||
uid = key_info.get("uid")
|
||||
is_known_benign_failure = (
|
||||
key_id,
|
||||
uid,
|
||||
) in KNOWN_BENIGN_KEY_GENERATION_FAILURES
|
||||
if not key_info.get("success", True) and not is_known_benign_failure:
|
||||
self.log.warning(
|
||||
"Certificate validation failure detected: %s",
|
||||
result.get("cert_validation_failure"),
|
||||
"Failed key generation detected for key_id: %s",
|
||||
key_id,
|
||||
)
|
||||
|
||||
# Flag key integrity violations
|
||||
if "key_integrity_violation" in result:
|
||||
# Flag certificate validation failures
|
||||
if "cert_validation_failure" in result:
|
||||
self.alertstore.medium(
|
||||
"Certificate validation failure detected: "
|
||||
f"{result.get('cert_validation_failure')}",
|
||||
result.get("timestamp") or "",
|
||||
result,
|
||||
)
|
||||
|
||||
# Flag key integrity violations
|
||||
if "key_integrity_violation" in result:
|
||||
self.alertstore.medium(
|
||||
f"Key integrity violation detected: {result.get('key_integrity_violation')}",
|
||||
result.get("timestamp") or "",
|
||||
result,
|
||||
)
|
||||
|
||||
# Flag successful certificate authority installations (potential
|
||||
# MITM); a missing success field is treated as installed
|
||||
if "cert_authority_installed" in result:
|
||||
cert_info = result["cert_authority_installed"]
|
||||
if cert_info.get("success", True):
|
||||
self.alertstore.medium(
|
||||
f"Key integrity violation detected: {result.get('key_integrity_violation')}",
|
||||
"Certificate authority installed: "
|
||||
f"{cert_info.get('subject', 'unknown')}",
|
||||
result.get("timestamp") or "",
|
||||
result,
|
||||
)
|
||||
|
||||
# Flag certificate authority installations (potential MITM)
|
||||
if "cert_authority_installed" in result:
|
||||
cert_info = result["cert_authority_installed"]
|
||||
else:
|
||||
self.log.warning(
|
||||
"Certificate authority installed: %s (success: %s)",
|
||||
"Failed certificate authority install attempt: %s",
|
||||
cert_info.get("subject", "unknown"),
|
||||
cert_info.get("success", "unknown"),
|
||||
)
|
||||
|
||||
# Flag wipe failures
|
||||
if "wipe_failure" in result:
|
||||
# Flag wipe failures
|
||||
if "wipe_failure" in result:
|
||||
self.alertstore.medium(
|
||||
"Device wipe failure detected",
|
||||
result.get("timestamp") or "",
|
||||
result,
|
||||
)
|
||||
|
||||
# Flag crypto self test failures
|
||||
if "crypto_self_test_completed" in result:
|
||||
test_result = result["crypto_self_test_completed"]
|
||||
if isinstance(test_result, dict):
|
||||
success = test_result.get("success", True)
|
||||
else:
|
||||
success = test_result == 1
|
||||
if not success:
|
||||
self.alertstore.medium(
|
||||
"Device wipe failure detected",
|
||||
"Cryptographic self test failed",
|
||||
result.get("timestamp") or "",
|
||||
result,
|
||||
)
|
||||
|
||||
# Flag crypto self test failures
|
||||
if "crypto_self_test_completed" in result:
|
||||
test_result = result["crypto_self_test_completed"]
|
||||
if isinstance(test_result, dict):
|
||||
success = test_result.get("success", True)
|
||||
else:
|
||||
success = test_result == 1
|
||||
if not success:
|
||||
self.alertstore.medium(
|
||||
"Cryptographic self test failed",
|
||||
result.get("timestamp") or "",
|
||||
result,
|
||||
)
|
||||
|
||||
def serialize(self, record: dict) -> Union[dict, list]:
|
||||
"""Serialize a security event record for timeline output."""
|
||||
# Determine the event sub-type
|
||||
|
||||
@@ -48,13 +48,16 @@ def parse_ab_header(data):
|
||||
'encryption': "none", 'version': 4}
|
||||
"""
|
||||
if data.startswith(b"ANDROID BACKUP"):
|
||||
[_, version, is_compressed, encryption, _] = data.split(b"\n", 4)
|
||||
return {
|
||||
"backup": True,
|
||||
"compression": (is_compressed == b"1"),
|
||||
"version": int(version),
|
||||
"encryption": encryption.decode("utf-8"),
|
||||
}
|
||||
try:
|
||||
[_, version, is_compressed, encryption, _] = data.split(b"\n", 4)
|
||||
return {
|
||||
"backup": True,
|
||||
"compression": (is_compressed == b"1"),
|
||||
"version": int(version),
|
||||
"encryption": encryption.decode("utf-8"),
|
||||
}
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return {"backup": False, "compression": None, "version": None, "encryption": None}
|
||||
|
||||
@@ -128,20 +131,23 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
|
||||
if password is None:
|
||||
raise InvalidBackupPassword()
|
||||
|
||||
[
|
||||
user_salt,
|
||||
checksum_salt,
|
||||
pbkdf2_rounds,
|
||||
user_iv,
|
||||
master_key_blob,
|
||||
encrypted_data,
|
||||
] = encrypted_backup.split(b"\n", 5)
|
||||
try:
|
||||
[
|
||||
user_salt,
|
||||
checksum_salt,
|
||||
pbkdf2_rounds,
|
||||
user_iv,
|
||||
master_key_blob,
|
||||
encrypted_data,
|
||||
] = encrypted_backup.split(b"\n", 5)
|
||||
|
||||
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
|
||||
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
|
||||
pbkdf2_rounds = int(pbkdf2_rounds)
|
||||
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
|
||||
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
|
||||
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
|
||||
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
|
||||
pbkdf2_rounds = int(pbkdf2_rounds)
|
||||
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
|
||||
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise AndroidBackupParsingError("Invalid encrypted backup header") from exc
|
||||
|
||||
# Derive decryption master key from password.
|
||||
master_key, master_iv = decrypt_master_key(
|
||||
@@ -171,10 +177,12 @@ def parse_backup_file(data, password=None):
|
||||
if not data.startswith(b"ANDROID BACKUP"):
|
||||
raise AndroidBackupParsingError("Invalid file header")
|
||||
|
||||
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
|
||||
|
||||
version = int(version)
|
||||
is_compressed = int(is_compressed)
|
||||
try:
|
||||
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
|
||||
version = int(version)
|
||||
is_compressed = int(is_compressed)
|
||||
except ValueError as exc:
|
||||
raise AndroidBackupParsingError("Invalid file header") from exc
|
||||
|
||||
if encryption_algo != b"none":
|
||||
tar_data = decrypt_backup_data(
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# 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 hashlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Iterable
|
||||
|
||||
import click
|
||||
|
||||
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
|
||||
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomCommandLoadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BrokenPluginCommand(click.Command):
|
||||
"""A placeholder for an installed or configured command that failed to load."""
|
||||
|
||||
def __init__(self, name: str, source: str, exception: BaseException):
|
||||
super().__init__(
|
||||
name,
|
||||
help=(
|
||||
f"Unable to load external command from {source}.\n\n"
|
||||
f"{type(exception).__name__}: {exception}"
|
||||
),
|
||||
short_help="Warning: external command could not be loaded.",
|
||||
)
|
||||
self.source = source
|
||||
self.exception = exception
|
||||
|
||||
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
||||
return args
|
||||
|
||||
def invoke(self, ctx: click.Context) -> None:
|
||||
raise click.ClickException(
|
||||
f"Unable to load external command '{self.name}' from {self.source}: "
|
||||
f"{type(self.exception).__name__}: {self.exception}"
|
||||
)
|
||||
|
||||
|
||||
def _module_name_for_path(path: Path) -> str:
|
||||
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
|
||||
return f"_mvt_custom_command_{path.stem}_{digest}"
|
||||
|
||||
|
||||
def _iter_command_files(path: Path) -> Iterable[Path]:
|
||||
if path.is_file():
|
||||
if path.suffix != ".py":
|
||||
raise CustomCommandLoadError(
|
||||
f"Custom command file is not a Python file: {path}"
|
||||
)
|
||||
yield path
|
||||
return
|
||||
|
||||
if path.is_dir():
|
||||
for child in sorted(path.iterdir()):
|
||||
if child.name.startswith(".") or child.name == "__init__.py":
|
||||
continue
|
||||
if child.is_file() and child.suffix == ".py":
|
||||
yield child
|
||||
return
|
||||
|
||||
raise CustomCommandLoadError(f"Custom command path does not exist: {path}")
|
||||
|
||||
|
||||
def _load_python_file(path: Path) -> ModuleType:
|
||||
module_name = _module_name_for_path(path)
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise CustomCommandLoadError(f"Unable to load custom command file: {path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except (Exception, SystemExit) as exc:
|
||||
raise CustomCommandLoadError(
|
||||
f"Unable to import custom command {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def load_cli_command_file(path: Path) -> click.Command:
|
||||
module = _load_python_file(path)
|
||||
command = getattr(module, "cli", None)
|
||||
if not isinstance(command, click.Command):
|
||||
raise CustomCommandLoadError(
|
||||
f"Custom command {path} must export a Click command or group named 'cli'"
|
||||
)
|
||||
if command.name is None:
|
||||
raise CustomCommandLoadError(f"Custom command {path} has no command name")
|
||||
return command
|
||||
|
||||
|
||||
def _register_command(
|
||||
group: click.Group,
|
||||
command: click.Command,
|
||||
*,
|
||||
name: str,
|
||||
source: str,
|
||||
collision_is_error: bool,
|
||||
) -> bool:
|
||||
if name in group.commands:
|
||||
registered_sources = getattr(group, "_mvt_external_command_sources", {})
|
||||
if registered_sources.get(name) == source:
|
||||
return False
|
||||
message = (
|
||||
f"Unable to register external command '{name}' from {source}: "
|
||||
"the command name is already registered"
|
||||
)
|
||||
if collision_is_error:
|
||||
raise CustomCommandLoadError(message)
|
||||
log.warning(message)
|
||||
return False
|
||||
|
||||
group.add_command(command, name=name)
|
||||
registered_sources = getattr(group, "_mvt_external_command_sources", {})
|
||||
registered_sources[name] = source
|
||||
setattr(group, "_mvt_external_command_sources", registered_sources)
|
||||
return True
|
||||
|
||||
|
||||
def register_cli_commands_from_path(
|
||||
group: click.Group,
|
||||
path: str | Path,
|
||||
*,
|
||||
collision_is_error: bool = False,
|
||||
failures_are_errors: bool = False,
|
||||
) -> list[str]:
|
||||
resolved_path = Path(path).expanduser().resolve()
|
||||
registered: list[str] = []
|
||||
|
||||
try:
|
||||
command_files = list(_iter_command_files(resolved_path))
|
||||
except CustomCommandLoadError as exc:
|
||||
if failures_are_errors:
|
||||
raise
|
||||
name = resolved_path.stem.replace("_", "-") or "custom-command"
|
||||
if _register_command(
|
||||
group,
|
||||
BrokenPluginCommand(name, str(resolved_path), exc),
|
||||
name=name,
|
||||
source=str(resolved_path),
|
||||
collision_is_error=collision_is_error,
|
||||
):
|
||||
registered.append(name)
|
||||
return registered
|
||||
|
||||
for command_file in command_files:
|
||||
try:
|
||||
command = load_cli_command_file(command_file)
|
||||
except CustomCommandLoadError as exc:
|
||||
if failures_are_errors:
|
||||
raise
|
||||
name = command_file.stem.replace("_", "-")
|
||||
command = BrokenPluginCommand(name, str(command_file), exc)
|
||||
|
||||
command_name = command.name
|
||||
if command_name is None:
|
||||
raise CustomCommandLoadError(
|
||||
f"Custom command {command_file} has no command name"
|
||||
)
|
||||
if _register_command(
|
||||
group,
|
||||
command,
|
||||
name=command_name,
|
||||
source=str(command_file),
|
||||
collision_is_error=collision_is_error,
|
||||
):
|
||||
registered.append(command_name)
|
||||
|
||||
return registered
|
||||
|
||||
|
||||
def _entry_point_source(entry_point: importlib.metadata.EntryPoint) -> str:
|
||||
try:
|
||||
distribution = getattr(entry_point, "dist", None)
|
||||
if distribution is None:
|
||||
return f"entry point {entry_point.value}"
|
||||
|
||||
name = distribution.metadata.get("Name", "unknown distribution")
|
||||
version = getattr(distribution, "version", None)
|
||||
if version:
|
||||
return f"{name} {version} ({entry_point.value})"
|
||||
return f"{name} ({entry_point.value})"
|
||||
except Exception:
|
||||
return f"entry point {entry_point.value}"
|
||||
|
||||
|
||||
def register_installed_cli_commands(
|
||||
group: click.Group,
|
||||
entry_point_group: str,
|
||||
) -> list[str]:
|
||||
try:
|
||||
entry_points = importlib.metadata.entry_points(group=entry_point_group)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"Unable to discover external commands in entry-point group %s: %s",
|
||||
entry_point_group,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
ordered_entry_points = sorted(
|
||||
entry_points,
|
||||
key=lambda entry_point: (
|
||||
entry_point.name,
|
||||
_entry_point_source(entry_point),
|
||||
entry_point.value,
|
||||
),
|
||||
)
|
||||
registered: list[str] = []
|
||||
|
||||
for entry_point in ordered_entry_points:
|
||||
source = _entry_point_source(entry_point)
|
||||
try:
|
||||
command = entry_point.load()
|
||||
if not isinstance(command, click.Command):
|
||||
raise TypeError(
|
||||
f"entry point must resolve to a Click command or group, "
|
||||
f"not {type(command).__name__}"
|
||||
)
|
||||
except (Exception, SystemExit) as exc:
|
||||
command = BrokenPluginCommand(entry_point.name, source, exc)
|
||||
|
||||
if _register_command(
|
||||
group,
|
||||
command,
|
||||
name=entry_point.name,
|
||||
source=source,
|
||||
collision_is_error=False,
|
||||
):
|
||||
registered.append(entry_point.name)
|
||||
|
||||
return registered
|
||||
|
||||
|
||||
def register_cli_plugins(
|
||||
group: click.Group,
|
||||
*,
|
||||
entry_point_group: str,
|
||||
environment_variable: str,
|
||||
) -> None:
|
||||
environment_path = os.environ.get(environment_variable)
|
||||
if environment_path:
|
||||
register_cli_commands_from_path(group, environment_path)
|
||||
register_installed_cli_commands(group, entry_point_group)
|
||||
|
||||
|
||||
def _load_command_option_callback(
|
||||
ctx: click.Context,
|
||||
param: click.Parameter,
|
||||
paths: tuple[str, ...],
|
||||
) -> tuple[str, ...]:
|
||||
if not isinstance(ctx.command, click.Group):
|
||||
raise click.ClickException("--load-command requires a Click command group")
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
register_cli_commands_from_path(
|
||||
ctx.command,
|
||||
path,
|
||||
collision_is_error=True,
|
||||
failures_are_errors=True,
|
||||
)
|
||||
except CustomCommandLoadError as exc:
|
||||
raise click.BadParameter(str(exc), ctx=ctx, param=param) from exc
|
||||
return paths
|
||||
|
||||
|
||||
load_cli_commands_option = click.option(
|
||||
"--load-command",
|
||||
"load_commands",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
expose_value=False,
|
||||
is_eager=True,
|
||||
callback=_load_command_option_callback,
|
||||
help=(
|
||||
"Load a custom CLI command from a Python file or folder "
|
||||
"(can be invoked multiple times)"
|
||||
),
|
||||
)
|
||||
@@ -8,6 +8,8 @@ import os
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.common.module_loader import get_module_logger
|
||||
from mvt.common.utils import exec_or_profile
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -26,6 +28,8 @@ class CmdCheckIOCS(Command):
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
platform: str = "",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -39,14 +43,16 @@ class CmdCheckIOCS(Command):
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
self.platform = platform
|
||||
self.name = "check-iocs"
|
||||
|
||||
def run(self) -> None:
|
||||
assert self.target_path is not None
|
||||
all_modules = []
|
||||
for entry in self.modules:
|
||||
for entry in self._available_modules():
|
||||
if entry not in all_modules:
|
||||
all_modules.append(entry)
|
||||
|
||||
@@ -71,7 +77,7 @@ class CmdCheckIOCS(Command):
|
||||
)
|
||||
|
||||
m = iocs_module.from_json(
|
||||
file_path, log=logging.getLogger(iocs_module.__module__)
|
||||
file_path, log=get_module_logger(iocs_module)
|
||||
)
|
||||
if not m:
|
||||
log.warning("No result from this module, skipping it")
|
||||
|
||||
+147
-14
@@ -8,6 +8,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from heapq import heappop, heappush
|
||||
from typing import Any, Optional
|
||||
|
||||
from rich.console import Console
|
||||
@@ -18,6 +19,13 @@ from .alerts import AlertLevel, AlertStore
|
||||
from .config import settings
|
||||
from .indicators import Indicators
|
||||
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
|
||||
from .module_loader import (
|
||||
ModuleOrigin,
|
||||
get_module_logger,
|
||||
get_module_origin,
|
||||
module_supports_command,
|
||||
)
|
||||
from .module_types import ModuleTimeline, URLResult
|
||||
from .utils import (
|
||||
CustomJSONEncoder,
|
||||
convert_datetime_to_iso,
|
||||
@@ -42,9 +50,12 @@ class Command:
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
self.name = ""
|
||||
self.modules: list[Any] = []
|
||||
self.platform = ""
|
||||
self.modules: list[type[MVTModule]] = []
|
||||
self.custom_modules = custom_modules if custom_modules else []
|
||||
|
||||
self.target_path = target_path
|
||||
self.results_path = results_path
|
||||
@@ -63,10 +74,11 @@ class Command:
|
||||
|
||||
# This list will contain all executed modules.
|
||||
# We can use this to reference e.g. self.executed[0].results.
|
||||
self.executed: list[Any] = []
|
||||
self.executed: list[MVTModule] = []
|
||||
self.hashes = hashes
|
||||
self.hash_values: list[dict[str, Any]] = []
|
||||
self.timeline: list[dict[str, Any]] = []
|
||||
self.timeline: ModuleTimeline = []
|
||||
self.url_results: list[URLResult] = []
|
||||
|
||||
# Load IOCs
|
||||
self._create_storage()
|
||||
@@ -144,6 +156,14 @@ class Command:
|
||||
with open(alerts_path, "w+", encoding="utf-8") as handle:
|
||||
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
|
||||
|
||||
def _store_urls(self) -> None:
|
||||
if not self.results_path or not self.url_results:
|
||||
return
|
||||
|
||||
urls_path = os.path.join(self.results_path, "urls.json")
|
||||
with open(urls_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(self.url_results, handle, indent=4, cls=CustomJSONEncoder)
|
||||
|
||||
def _store_alerts_timeline(self) -> None:
|
||||
if not self.results_path:
|
||||
return
|
||||
@@ -195,10 +215,49 @@ class Command:
|
||||
for file in generate_hashes_from_path(self.target_path, self.log):
|
||||
self.hash_values.append(file)
|
||||
|
||||
@staticmethod
|
||||
def _modules_by_origin(
|
||||
modules: list[type[MVTModule]],
|
||||
) -> dict[ModuleOrigin, list[str]]:
|
||||
origins: dict[ModuleOrigin, list[str]] = {}
|
||||
for module in modules:
|
||||
origins.setdefault(get_module_origin(module), []).append(module.__name__)
|
||||
return origins
|
||||
|
||||
def list_modules(self) -> None:
|
||||
self.log.info("Following is the list of available %s modules:", self.name)
|
||||
for module in self.modules:
|
||||
self.log.info(" - %s", module.__name__)
|
||||
for origin, module_names in self._modules_by_origin(
|
||||
self._available_modules()
|
||||
).items():
|
||||
self.log.info(
|
||||
" - Modules from %s: %s", origin.label, ", ".join(module_names)
|
||||
)
|
||||
|
||||
def _log_loaded_modules(self, modules: list[type[MVTModule]]) -> None:
|
||||
"""Record the loaded modules and their origins for auditability."""
|
||||
for origin, module_names in self._modules_by_origin(modules).items():
|
||||
self.log.info(
|
||||
"Loaded %d %s modules from %s: %s",
|
||||
len(module_names),
|
||||
self.name,
|
||||
origin.label,
|
||||
", ".join(module_names),
|
||||
)
|
||||
|
||||
def _available_modules(self) -> list[type[MVTModule]]:
|
||||
modules = list(self.modules)
|
||||
modules.extend(
|
||||
module
|
||||
for module in self.custom_modules
|
||||
if module_supports_command(module, self.platform, self.name)
|
||||
)
|
||||
|
||||
deduplicated = []
|
||||
for module in modules:
|
||||
if module not in deduplicated:
|
||||
deduplicated.append(module)
|
||||
|
||||
return deduplicated
|
||||
|
||||
def init(self) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -258,22 +317,89 @@ class Command:
|
||||
console.print("")
|
||||
console.print(panel)
|
||||
|
||||
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
|
||||
"""Return enabled modules in stable topological order."""
|
||||
modules = self._available_modules()
|
||||
module_indexes = {module: index for index, module in enumerate(modules)}
|
||||
|
||||
if self.module_name:
|
||||
selected = [
|
||||
module for module in modules if module.__name__ == self.module_name
|
||||
]
|
||||
else:
|
||||
selected = [module for module in modules if module.enabled]
|
||||
|
||||
required = set(selected)
|
||||
pending = list(selected)
|
||||
while pending:
|
||||
module = pending.pop()
|
||||
for dependency in module.dependencies:
|
||||
if dependency not in module_indexes:
|
||||
self.log.warning(
|
||||
"Module %s depends on unavailable module %s. "
|
||||
"No modules will be run.",
|
||||
module.__name__,
|
||||
dependency.__name__,
|
||||
)
|
||||
return None
|
||||
if dependency not in required:
|
||||
required.add(dependency)
|
||||
pending.append(dependency)
|
||||
|
||||
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
|
||||
module: [] for module in required
|
||||
}
|
||||
indegree = {module: 0 for module in required}
|
||||
for module in required:
|
||||
for dependency in module.dependencies:
|
||||
if dependency not in required:
|
||||
continue
|
||||
dependents[dependency].append(module)
|
||||
indegree[module] += 1
|
||||
|
||||
ready: list[tuple[int, type[MVTModule]]] = []
|
||||
for module, count in indegree.items():
|
||||
if count == 0:
|
||||
heappush(ready, (module_indexes[module], module))
|
||||
|
||||
ordered = []
|
||||
while ready:
|
||||
_, module = heappop(ready)
|
||||
ordered.append(module)
|
||||
for dependent in dependents[module]:
|
||||
indegree[dependent] -= 1
|
||||
if indegree[dependent] == 0:
|
||||
heappush(ready, (module_indexes[dependent], dependent))
|
||||
|
||||
if len(ordered) != len(required):
|
||||
cyclic_modules = sorted(
|
||||
(module.__name__ for module, count in indegree.items() if count > 0)
|
||||
)
|
||||
self.log.warning(
|
||||
"Circular module dependency detected involving: %s. "
|
||||
"No modules will be run.",
|
||||
", ".join(cyclic_modules),
|
||||
)
|
||||
return None
|
||||
|
||||
return ordered
|
||||
|
||||
def run(self) -> None:
|
||||
ordered_modules = self._ordered_modules()
|
||||
if ordered_modules is None:
|
||||
return
|
||||
|
||||
self._log_loaded_modules(ordered_modules)
|
||||
|
||||
try:
|
||||
self.init()
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
for module in self.modules:
|
||||
if self.module_name and module.__name__ != self.module_name:
|
||||
continue
|
||||
executed_by_type: dict[type[MVTModule], MVTModule] = {}
|
||||
for module in ordered_modules:
|
||||
|
||||
if not module.enabled and not (
|
||||
self.module_name and module.__name__ == self.module_name
|
||||
):
|
||||
continue
|
||||
|
||||
module_logger = logging.getLogger(module.__module__)
|
||||
module_logger = get_module_logger(module)
|
||||
|
||||
m = module(
|
||||
target_path=self.target_path,
|
||||
@@ -281,6 +407,10 @@ class Command:
|
||||
module_options=self.module_options,
|
||||
log=module_logger,
|
||||
)
|
||||
m.dependency_modules = {
|
||||
dependency: executed_by_type[dependency]
|
||||
for dependency in module.dependencies
|
||||
}
|
||||
|
||||
if self.iocs.total_ioc_count:
|
||||
m.indicators = self.iocs
|
||||
@@ -304,7 +434,9 @@ class Command:
|
||||
return
|
||||
|
||||
self.executed.append(m)
|
||||
executed_by_type[module] = m
|
||||
self.timeline.extend(m.timeline)
|
||||
self.url_results.extend(m.url_results)
|
||||
self.alertstore.extend(m.alertstore.alerts)
|
||||
|
||||
try:
|
||||
@@ -319,4 +451,5 @@ class Command:
|
||||
self._store_timeline()
|
||||
self._store_alerts_timeline()
|
||||
self._store_alerts()
|
||||
self._store_urls()
|
||||
self._store_info()
|
||||
@@ -0,0 +1,94 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
|
||||
import click
|
||||
from click.shell_completion import get_completion_class
|
||||
|
||||
|
||||
SUPPORTED_SHELLS = ("bash", "zsh", "fish")
|
||||
|
||||
|
||||
def completion_instructions(program_name: str) -> str:
|
||||
return f"""Shell completion for {program_name}
|
||||
|
||||
Print a completion script:
|
||||
{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:
|
||||
[ -f ~/.{program_name}-complete.bash ] && . ~/.{program_name}-complete.bash
|
||||
|
||||
Load the generated Zsh script from ~/.zshrc:
|
||||
[ -f ~/.{program_name}-complete.zsh ] && . ~/.{program_name}-complete.zsh
|
||||
|
||||
Fish loads completion files from ~/.config/fish/completions automatically.
|
||||
|
||||
To write these files and update Bash/Zsh shell configuration automatically:
|
||||
{program_name} completion bash --install
|
||||
{program_name} completion zsh --install
|
||||
{program_name} completion fish --install
|
||||
"""
|
||||
|
||||
|
||||
def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str:
|
||||
completion_class = get_completion_class(shell)
|
||||
if completion_class is None:
|
||||
raise click.ClickException(f"Unsupported shell: {shell}")
|
||||
|
||||
complete_var = f"_{program_name.upper().replace('-', '_')}_COMPLETE"
|
||||
return completion_class(cli, {}, program_name, complete_var).source()
|
||||
|
||||
|
||||
def install_completion_script(
|
||||
cli: click.Command,
|
||||
program_name: str,
|
||||
shell: str,
|
||||
) -> Path:
|
||||
script = generate_completion_script(cli, program_name, shell)
|
||||
script_path = _completion_script_path(program_name, shell)
|
||||
script_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
script_path.write_text(script, encoding="utf-8")
|
||||
|
||||
if shell in ("bash", "zsh"):
|
||||
_install_shell_source_line(program_name, shell, script_path)
|
||||
|
||||
return script_path
|
||||
|
||||
|
||||
def _completion_script_path(program_name: str, shell: str) -> Path:
|
||||
home = Path.home()
|
||||
|
||||
if shell == "fish":
|
||||
return home / ".config" / "fish" / "completions" / f"{program_name}.fish"
|
||||
|
||||
return home / f".{program_name}-complete.{shell}"
|
||||
|
||||
|
||||
def _install_shell_source_line(program_name: str, shell: str, script_path: Path) -> None:
|
||||
shell_config_path = Path.home() / f".{shell}rc"
|
||||
source_line = (
|
||||
f"[ -f {shlex.quote(str(script_path))} ] && "
|
||||
f". {shlex.quote(str(script_path))}"
|
||||
)
|
||||
block = (
|
||||
f"# MVT shell completion for {program_name}\n"
|
||||
f"{source_line}\n"
|
||||
)
|
||||
|
||||
if shell_config_path.exists():
|
||||
shell_config = shell_config_path.read_text(encoding="utf-8")
|
||||
if source_line in shell_config:
|
||||
return
|
||||
else:
|
||||
shell_config = ""
|
||||
|
||||
separator = "" if not shell_config or shell_config.endswith("\n") else "\n"
|
||||
with shell_config_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{separator}{block}")
|
||||
@@ -8,8 +8,12 @@ HELP_MSG_VERSION = "Show the currently installed version of MVT"
|
||||
HELP_MSG_OUTPUT = "Specify a path to a folder where you want to store JSON results"
|
||||
HELP_MSG_IOC = "Path to indicators file (can be invoked multiple time)"
|
||||
HELP_MSG_FAST = "Avoid running time/resource consuming features"
|
||||
HELP_MSG_LIST_MODULES = "Print list of available modules and exit"
|
||||
HELP_MSG_LIST_MODULES = "Print list of available modules and their source, then exit"
|
||||
HELP_MSG_MODULE = "Name of a single module you would like to run instead of all"
|
||||
HELP_MSG_LOAD_MODULE = (
|
||||
"Load custom MVT module(s) from a Python file or folder "
|
||||
"(can be invoked multiple times)"
|
||||
)
|
||||
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
|
||||
HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
|
||||
HELP_MSG_VERBOSE = "Verbose mode"
|
||||
@@ -17,6 +21,7 @@ HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators"
|
||||
HELP_MSG_STIX2 = "Download public STIX2 indicators"
|
||||
HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check"
|
||||
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check"
|
||||
HELP_MSG_COMPLETION = "Generate or install shell completion"
|
||||
|
||||
# IOS Specific
|
||||
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
|
||||
@@ -33,6 +38,7 @@ HELP_MSG_BACKUP_KEYFILE = (
|
||||
HELP_MSG_EXTRACT_KEY = "Extract decryption key from an iTunes backup"
|
||||
HELP_MSG_CHECK_IOS_BACKUP = "Extract artifacts from an iTunes backup"
|
||||
HELP_MSG_CHECK_FS = "Extract artifacts from a full filesystem dump"
|
||||
HELP_MSG_CHECK_SYSDIAGNOSE = "Extract artifacts from an iOS sysdiagnose archive"
|
||||
|
||||
# Android Specific
|
||||
HELP_MSG_ANDROID_BACKUP_PASSWORD = "The backup password to use for an Android backup"
|
||||
@@ -48,3 +54,5 @@ HELP_MSG_CHECK_BUGREPORT = "Check an Android Bug Report"
|
||||
HELP_MSG_CHECK_ANDROID_BACKUP = "Check an Android Backup"
|
||||
HELP_MSG_CHECK_ANDROIDQF = "Check data collected with AndroidQF"
|
||||
HELP_MSG_CHECK_INTRUSION_LOGS = "Check Android Intrusion Logging files"
|
||||
HELP_MSG_VIRUS_TOTAL = "Check package APK hashes on VirusTotal"
|
||||
HELP_MSG_DELAY_CHECKS = "Delay in seconds between VirusTotal requests"
|
||||
@@ -7,9 +7,10 @@ import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
from typing import Any, Dict, Iterator, List, Optional, Sequence
|
||||
|
||||
import ahocorasick
|
||||
from appdirs import user_data_dir
|
||||
@@ -22,6 +23,8 @@ MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
URL_CHECK_MAX_WORKERS = 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class Indicator:
|
||||
@@ -46,6 +49,7 @@ class Indicators:
|
||||
self.log = log
|
||||
self.ioc_collections: List[Dict[str, Any]] = []
|
||||
self.total_ioc_count = 0
|
||||
self.resolved_urls: Dict[str, str] = {}
|
||||
|
||||
def _load_downloaded_indicators(self) -> None:
|
||||
if not os.path.isdir(MVT_INDICATORS_FOLDER):
|
||||
@@ -436,9 +440,14 @@ class Indicators:
|
||||
orig_url.url,
|
||||
dest_url.url,
|
||||
)
|
||||
return self.check_url(dest_url.url)
|
||||
match = self.check_url(dest_url.url)
|
||||
self.resolved_urls[url] = self.resolved_urls.get(
|
||||
dest_url.url, dest_url.url
|
||||
)
|
||||
return match
|
||||
|
||||
final_url = dest_url
|
||||
self.resolved_urls[url] = final_url.url
|
||||
else:
|
||||
# If it's not shortened, we just use the original URL object.
|
||||
final_url = orig_url
|
||||
@@ -479,6 +488,13 @@ class Indicators:
|
||||
|
||||
return None
|
||||
|
||||
def get_expanded_url(self, url: str) -> Optional[str]:
|
||||
"""Return the final URL recorded while checking a shortened URL."""
|
||||
expanded_url = self.resolved_urls.get(url)
|
||||
if expanded_url and expanded_url != url:
|
||||
return expanded_url
|
||||
return None
|
||||
|
||||
def check_urls(self, urls: list) -> Optional[IndicatorMatch]:
|
||||
"""Check a list of URLs against the provided list of domain indicators.
|
||||
|
||||
@@ -490,12 +506,41 @@ class Indicators:
|
||||
if not urls:
|
||||
return None
|
||||
|
||||
for url in urls:
|
||||
check = self.check_url(url)
|
||||
if check:
|
||||
return check
|
||||
return self.check_url_batches([urls])[0]
|
||||
|
||||
return None
|
||||
def check_url_batches(
|
||||
self, url_batches: Sequence[Optional[Sequence[str]]]
|
||||
) -> List[Optional[IndicatorMatch]]:
|
||||
"""Check batches of URLs concurrently while preserving batch order.
|
||||
|
||||
URLs are deduplicated across batches before checking. Each returned item
|
||||
is the first indicator match from the corresponding input batch, using
|
||||
the original URL order.
|
||||
"""
|
||||
batches = [list(urls) if urls else [] for urls in url_batches]
|
||||
unique_urls = list(
|
||||
dict.fromkeys(url for urls in batches for url in urls)
|
||||
)
|
||||
|
||||
if not unique_urls:
|
||||
return [None] * len(batches)
|
||||
|
||||
if settings.NETWORK_ACCESS_ALLOWED and len(unique_urls) > 1:
|
||||
worker_count = min(URL_CHECK_MAX_WORKERS, len(unique_urls))
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||
url_matches = dict(
|
||||
zip(unique_urls, executor.map(self.check_url, unique_urls))
|
||||
)
|
||||
else:
|
||||
url_matches = {url: self.check_url(url) for url in unique_urls}
|
||||
|
||||
return [
|
||||
next(
|
||||
(url_matches[url] for url in urls if url_matches[url] is not None),
|
||||
None,
|
||||
)
|
||||
for urls in batches
|
||||
]
|
||||
|
||||
def check_process(self, process: str) -> Optional[IndicatorMatch]:
|
||||
"""Check the provided process name against the list of process
|
||||
@@ -727,29 +772,6 @@ class Indicators:
|
||||
|
||||
return None
|
||||
|
||||
def check_receiver_prefix(
|
||||
self, receiver_name: str
|
||||
) -> Optional[IndicatorMatch]:
|
||||
"""Check the provided receiver name against the list of indicators.
|
||||
An IoC match is detected when a substring of the receiver matches the indicator.
|
||||
|
||||
:param receiver_name: Receiver name to check against app ID indicators
|
||||
:type receiver_name: str
|
||||
:returns: IndicatorMatch if matched, otherwise None
|
||||
|
||||
"""
|
||||
if not receiver_name:
|
||||
return None
|
||||
|
||||
for ioc in self.get_iocs("app_ids"):
|
||||
if ioc.value.lower() in receiver_name.lower():
|
||||
return IndicatorMatch(
|
||||
ioc=ioc,
|
||||
message=f'Found a known suspicious receiver with name "{receiver_name}" matching indicators from "{ioc.name}"',
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def check_android_property_name(
|
||||
self, property_name: str
|
||||
) -> Optional[IndicatorMatch]:
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from .alerts import AlertStore
|
||||
from .indicators import Indicators
|
||||
@@ -18,6 +18,7 @@ from .module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
ModuleTimeline,
|
||||
URLResult,
|
||||
)
|
||||
from .utils import CustomJSONEncoder, exec_or_profile
|
||||
|
||||
@@ -43,6 +44,8 @@ class MVTModule:
|
||||
|
||||
enabled: bool = True
|
||||
slug: Optional[str] = None
|
||||
dependencies: Sequence[type["MVTModule"]] = ()
|
||||
supported_commands: Sequence[tuple[str, str]] = ()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -71,6 +74,7 @@ class MVTModule:
|
||||
self.file_path: Optional[str] = file_path
|
||||
self.target_path: Optional[str] = target_path
|
||||
self.results_path: Optional[str] = results_path
|
||||
self.serial: Optional[str] = None
|
||||
self.module_options: Dict[str, Any] = module_options if module_options else {}
|
||||
|
||||
self.log = log
|
||||
@@ -79,6 +83,14 @@ class MVTModule:
|
||||
|
||||
self.results: ModuleResults = results if results is not None else []
|
||||
self.timeline: ModuleTimeline = []
|
||||
self.url_results: list[URLResult] = []
|
||||
self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {}
|
||||
|
||||
def get_dependency_results(
|
||||
self, module_class: type["MVTModule"]
|
||||
) -> ModuleResults:
|
||||
"""Return the results produced by a prerequisite module."""
|
||||
return self.dependency_modules[module_class].results
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_path: str, log: logging.Logger):
|
||||
@@ -100,6 +112,23 @@ class MVTModule:
|
||||
def check_indicators(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def collect_url_results(self) -> None:
|
||||
"""Collect URL records exposed by this module."""
|
||||
|
||||
def add_url_result(self, url: str, timestamp: Optional[str], source: str) -> None:
|
||||
expanded_url = None
|
||||
if self.indicators:
|
||||
expanded_url = self.indicators.get_expanded_url(url)
|
||||
|
||||
self.url_results.append(
|
||||
{
|
||||
"url": url,
|
||||
"expanded_url": expanded_url,
|
||||
"timestamp": timestamp,
|
||||
"source": source,
|
||||
}
|
||||
)
|
||||
|
||||
def save_to_json(self) -> None:
|
||||
if not self.results_path:
|
||||
return
|
||||
@@ -239,6 +268,15 @@ def run_module(module: MVTModule) -> None:
|
||||
"The %s module produced no detections!", module.__class__.__name__
|
||||
)
|
||||
|
||||
try:
|
||||
module.collect_url_results()
|
||||
except Exception as exc:
|
||||
module.log.exception(
|
||||
"Error when collecting URLs from module %s: %s",
|
||||
module.__class__.__name__,
|
||||
exc,
|
||||
)
|
||||
|
||||
try:
|
||||
module.to_timeline()
|
||||
except NotImplementedError:
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
# 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 hashlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from .module import MVTModule
|
||||
from .version import MVT_VERSION
|
||||
|
||||
MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES"
|
||||
MODULES_ENTRY_POINT_GROUP = "mvt.modules"
|
||||
EXTERNAL_LOGGER_NAMESPACE = "mvt.ext"
|
||||
PLUGIN_PACKAGE_PREFIX = "mvt_plugin_"
|
||||
_ORIGIN_ATTRIBUTE = "_mvt_module_origin"
|
||||
_PATH_MODULE_PREFIX = "_mvt_custom_module_"
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomModuleLoadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModuleOrigin:
|
||||
"""Describes where a loaded module came from, for auditability.
|
||||
|
||||
``kind`` is one of ``builtin`` (shipped with MVT), ``package`` (loaded
|
||||
from an installed package) or ``path`` (loaded from a file passed with
|
||||
``--load-module`` or the environment variable).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
name: str
|
||||
version: Optional[str] = None
|
||||
commit: Optional[str] = None
|
||||
file_sha256: Optional[str] = None
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
label = self.name
|
||||
if self.version:
|
||||
label += f"@{self.version}"
|
||||
label = f"'{label}'"
|
||||
if self.commit:
|
||||
label += f" (commit {self.commit})"
|
||||
if self.file_sha256:
|
||||
label += f" (sha256: {self.file_sha256})"
|
||||
return label
|
||||
|
||||
|
||||
def _module_name_for_path(path: Path) -> str:
|
||||
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
|
||||
return f"{_PATH_MODULE_PREFIX}{path.stem}_{digest}"
|
||||
|
||||
|
||||
def get_module_logger(module_class: type[MVTModule]) -> logging.Logger:
|
||||
"""Return the logger a module's records should be emitted through.
|
||||
|
||||
Modules loaded from installed packages or file paths live outside the
|
||||
"mvt" logger hierarchy, so their records would never reach the handlers
|
||||
attached to the "mvt" logger and instead fall through to
|
||||
logging.lastResort (which prints bare messages and drops anything below
|
||||
WARNING). Their loggers are parented under the "mvt.ext" namespace,
|
||||
keeping external module names from colliding with MVT's own logger
|
||||
tree. File-path modules are named after their file instead of the
|
||||
mangled internal import name, and packages following the recommended
|
||||
"mvt_plugin_<name>" naming convention log under "mvt.ext.<name>".
|
||||
"""
|
||||
name = module_class.__module__
|
||||
if name == "mvt" or name.startswith("mvt."):
|
||||
return logging.getLogger(name)
|
||||
|
||||
if name.startswith(_PATH_MODULE_PREFIX):
|
||||
name = Path(get_module_origin(module_class).name).stem
|
||||
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(f"{EXTERNAL_LOGGER_NAMESPACE}.{name}")
|
||||
|
||||
|
||||
def _iter_module_files(path: Path) -> Iterable[Path]:
|
||||
if path.is_file():
|
||||
if path.suffix != ".py":
|
||||
raise CustomModuleLoadError(
|
||||
f"Custom module file is not a Python file: {path}"
|
||||
)
|
||||
yield path
|
||||
return
|
||||
|
||||
if path.is_dir():
|
||||
for child in sorted(path.iterdir()):
|
||||
if child.name.startswith("."):
|
||||
continue
|
||||
if child.name == "__init__.py":
|
||||
continue
|
||||
if child.is_file() and child.suffix == ".py":
|
||||
yield child
|
||||
return
|
||||
|
||||
raise CustomModuleLoadError(f"Custom module path does not exist: {path}")
|
||||
|
||||
|
||||
def _load_python_file(path: Path) -> ModuleType:
|
||||
module_name = _module_name_for_path(path)
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise CustomModuleLoadError(f"Unable to load custom module file: {path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as exc:
|
||||
raise CustomModuleLoadError(
|
||||
f"Unable to import custom module {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def discover_mvt_modules(module: ModuleType) -> list[type[MVTModule]]:
|
||||
modules = []
|
||||
for _, obj in inspect.getmembers(module, inspect.isclass):
|
||||
if obj is MVTModule:
|
||||
continue
|
||||
if obj.__module__ != module.__name__:
|
||||
continue
|
||||
if not issubclass(obj, MVTModule):
|
||||
continue
|
||||
modules.append(obj)
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def load_custom_modules_from_path(path: str) -> list[type[MVTModule]]:
|
||||
custom_modules: list[type[MVTModule]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
resolved_path = Path(path).expanduser().resolve()
|
||||
|
||||
for module_file in _iter_module_files(resolved_path):
|
||||
file_sha256 = hashlib.sha256(module_file.read_bytes()).hexdigest()
|
||||
loaded_module = _load_python_file(module_file)
|
||||
origin = ModuleOrigin(
|
||||
kind="path", name=str(module_file), file_sha256=file_sha256
|
||||
)
|
||||
for module_class in discover_mvt_modules(loaded_module):
|
||||
key = (str(module_file), module_class.__qualname__)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
setattr(module_class, _ORIGIN_ATTRIBUTE, origin)
|
||||
custom_modules.append(module_class)
|
||||
|
||||
return custom_modules
|
||||
|
||||
|
||||
def _module_key(module_class: type[MVTModule]) -> tuple[str, str]:
|
||||
try:
|
||||
source = str(Path(inspect.getfile(module_class)).resolve())
|
||||
except (OSError, TypeError):
|
||||
source = module_class.__module__
|
||||
return (source, module_class.__qualname__)
|
||||
|
||||
|
||||
def _distribution_commit(dist: importlib.metadata.Distribution) -> Optional[str]:
|
||||
"""Return the VCS commit a distribution was installed from, if recorded.
|
||||
|
||||
Packages installed directly from a repository (``pip install git+...``)
|
||||
record the commit in ``direct_url.json`` (PEP 610).
|
||||
"""
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
def _entry_point_origin(entry_point: importlib.metadata.EntryPoint) -> ModuleOrigin:
|
||||
name = entry_point.name
|
||||
version = None
|
||||
commit = None
|
||||
# Manually constructed entry points have no associated distribution.
|
||||
dist = getattr(entry_point, "dist", None)
|
||||
if dist is not None:
|
||||
try:
|
||||
name = dist.name or name
|
||||
version = dist.version
|
||||
except Exception:
|
||||
pass
|
||||
commit = _distribution_commit(dist)
|
||||
return ModuleOrigin(kind="package", name=name, version=version, commit=commit)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _packages_distributions() -> dict[str, list[str]]:
|
||||
try:
|
||||
return dict(importlib.metadata.packages_distributions())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_module_origin(module_class: type[MVTModule]) -> ModuleOrigin:
|
||||
"""Return the origin of a module class for auditing purposes."""
|
||||
origin = module_class.__dict__.get(_ORIGIN_ATTRIBUTE)
|
||||
if isinstance(origin, ModuleOrigin):
|
||||
return origin
|
||||
|
||||
top_level = module_class.__module__.partition(".")[0]
|
||||
if top_level == "mvt":
|
||||
return ModuleOrigin(kind="builtin", name="mvt", version=MVT_VERSION)
|
||||
|
||||
distributions = _packages_distributions().get(top_level)
|
||||
if distributions:
|
||||
name = distributions[0]
|
||||
version = None
|
||||
commit = None
|
||||
try:
|
||||
dist = importlib.metadata.distribution(name)
|
||||
version = dist.version
|
||||
commit = _distribution_commit(dist)
|
||||
except Exception:
|
||||
pass
|
||||
return ModuleOrigin(kind="package", name=name, version=version, commit=commit)
|
||||
|
||||
try:
|
||||
source = str(Path(inspect.getfile(module_class)).resolve())
|
||||
except (OSError, TypeError):
|
||||
source = module_class.__module__
|
||||
return ModuleOrigin(kind="path", name=source)
|
||||
|
||||
|
||||
def load_installed_modules() -> list[type[MVTModule]]:
|
||||
"""Load MVT modules registered by installed packages.
|
||||
|
||||
Packages register modules in the ``mvt.modules`` entry-point group. Each
|
||||
entry point must resolve to an iterable of MVTModule subclasses, or to a
|
||||
callable which returns one. A broken entry point is skipped with a
|
||||
warning so that a faulty plugin package cannot break MVT.
|
||||
"""
|
||||
try:
|
||||
entry_points = importlib.metadata.entry_points(group=MODULES_ENTRY_POINT_GROUP)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"Unable to discover installed module packages in entry-point group %s: %s",
|
||||
MODULES_ENTRY_POINT_GROUP,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
|
||||
installed_modules: list[type[MVTModule]] = []
|
||||
ordered_entry_points = sorted(
|
||||
entry_points, key=lambda entry_point: (entry_point.name, entry_point.value)
|
||||
)
|
||||
for entry_point in ordered_entry_points:
|
||||
try:
|
||||
loaded = entry_point.load()
|
||||
if callable(loaded) and not isinstance(loaded, type):
|
||||
loaded = loaded()
|
||||
module_classes = list(loaded)
|
||||
except (Exception, SystemExit) as exc:
|
||||
log.warning(
|
||||
"Unable to load modules from entry point %s (%s): %s",
|
||||
entry_point.name,
|
||||
entry_point.value,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
origin = _entry_point_origin(entry_point)
|
||||
for module_class in module_classes:
|
||||
if not (
|
||||
isinstance(module_class, type) and issubclass(module_class, MVTModule)
|
||||
):
|
||||
log.warning(
|
||||
"Entry point %s (%s) provided %r which is not an "
|
||||
"MVTModule subclass",
|
||||
entry_point.name,
|
||||
entry_point.value,
|
||||
module_class,
|
||||
)
|
||||
continue
|
||||
setattr(module_class, _ORIGIN_ATTRIBUTE, origin)
|
||||
installed_modules.append(module_class)
|
||||
|
||||
return installed_modules
|
||||
|
||||
|
||||
def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTModule]]:
|
||||
search_paths: list[str] = []
|
||||
env_path = os.environ.get(MVT_CUSTOM_MODULES_ENV)
|
||||
if env_path:
|
||||
search_paths.append(env_path)
|
||||
if paths:
|
||||
search_paths.extend(paths)
|
||||
|
||||
custom_modules: list[type[MVTModule]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for module_class in load_installed_modules():
|
||||
key = _module_key(module_class)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
custom_modules.append(module_class)
|
||||
|
||||
for path in search_paths:
|
||||
for module_class in load_custom_modules_from_path(path):
|
||||
key = _module_key(module_class)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
custom_modules.append(module_class)
|
||||
|
||||
return custom_modules
|
||||
|
||||
|
||||
def module_supports_command(
|
||||
module_class: type[MVTModule],
|
||||
platform: str,
|
||||
command: str,
|
||||
) -> bool:
|
||||
supported_commands = getattr(module_class, "supported_commands", None)
|
||||
if not supported_commands:
|
||||
log.warning(
|
||||
"Custom module %s has no supported_commands and will not be run. "
|
||||
"Declare the platform/command pairs it supports.",
|
||||
module_class.__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
return (platform, command) in {tuple(entry) for entry in supported_commands}
|
||||
@@ -4,7 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
|
||||
|
||||
# ModuleAtomicResult is a flexible dictionary that can contain any data.
|
||||
@@ -22,6 +22,13 @@ ModuleAtomicResult = Dict[str, Any]
|
||||
ModuleResults = Any
|
||||
|
||||
|
||||
class URLResult(TypedDict):
|
||||
url: str
|
||||
expanded_url: Optional[str]
|
||||
timestamp: Optional[str]
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleAtomicTimeline:
|
||||
timestamp: str
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# 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/
|
||||
|
||||
"""Password prompts with masked keyboard feedback.
|
||||
|
||||
This is based on the ``echo_char`` support added to :mod:`getpass` in Python
|
||||
3.14. MVT supports Python 3.10 and later, so it cannot use that API directly.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from typing import TextIO
|
||||
|
||||
|
||||
def prompt_password(prompt: str) -> str:
|
||||
"""Return a password while displaying an asterisk for each character."""
|
||||
try:
|
||||
import termios
|
||||
|
||||
return _unix_getpass(prompt, termios)
|
||||
except ImportError:
|
||||
try:
|
||||
import msvcrt
|
||||
|
||||
return _windows_getpass(prompt, msvcrt)
|
||||
except ImportError:
|
||||
return _fallback_getpass(prompt)
|
||||
|
||||
|
||||
def _unix_getpass(prompt: str, termios) -> str:
|
||||
password = None
|
||||
input_stream: TextIO
|
||||
output_stream: TextIO
|
||||
with contextlib.ExitStack() as stack:
|
||||
try:
|
||||
fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
|
||||
tty = io.FileIO(fd, "w+")
|
||||
stack.enter_context(tty)
|
||||
input_stream = io.TextIOWrapper(tty)
|
||||
stack.enter_context(input_stream)
|
||||
output_stream = input_stream
|
||||
except OSError:
|
||||
stack.close()
|
||||
try:
|
||||
fd = sys.stdin.fileno()
|
||||
except (AttributeError, ValueError):
|
||||
return _fallback_getpass(prompt)
|
||||
input_stream = sys.stdin
|
||||
output_stream = sys.stderr
|
||||
|
||||
try:
|
||||
old = termios.tcgetattr(fd)
|
||||
new = old[:]
|
||||
new[3] &= ~termios.ECHO
|
||||
new[3] &= ~termios.ICANON
|
||||
try:
|
||||
termios.tcsetattr(fd, termios.TCSAFLUSH, new)
|
||||
password = _readline_with_asterisks(output_stream, input_stream, prompt)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
|
||||
output_stream.flush()
|
||||
except termios.error:
|
||||
if password is not None:
|
||||
raise
|
||||
password = _fallback_getpass(prompt)
|
||||
|
||||
output_stream.write("\n")
|
||||
return password
|
||||
|
||||
|
||||
def _windows_getpass(prompt: str, msvcrt) -> str:
|
||||
if sys.stdin is not sys.__stdin__:
|
||||
return _fallback_getpass(prompt)
|
||||
|
||||
for char in prompt:
|
||||
msvcrt.putwch(char)
|
||||
|
||||
password = ""
|
||||
while True:
|
||||
char = msvcrt.getwch()
|
||||
if char in ("\r", "\n"):
|
||||
break
|
||||
if char == "\x03":
|
||||
raise KeyboardInterrupt
|
||||
if char == "\b":
|
||||
if password:
|
||||
msvcrt.putwch("\b")
|
||||
msvcrt.putwch(" ")
|
||||
msvcrt.putwch("\b")
|
||||
password = password[:-1]
|
||||
else:
|
||||
password += char
|
||||
msvcrt.putwch("*")
|
||||
|
||||
msvcrt.putwch("\r")
|
||||
msvcrt.putwch("\n")
|
||||
return password
|
||||
|
||||
|
||||
def _fallback_getpass(prompt: str) -> str:
|
||||
warnings.warn(
|
||||
"Can not control echo on the terminal.",
|
||||
category=UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
print("Warning: Password input may be echoed.", file=sys.stderr)
|
||||
return input(prompt)
|
||||
|
||||
|
||||
def _readline_with_asterisks(
|
||||
output_stream: TextIO, input_stream: TextIO, prompt: str
|
||||
) -> str:
|
||||
output_stream.write(prompt)
|
||||
output_stream.flush()
|
||||
|
||||
password = ""
|
||||
eof_pressed = False
|
||||
while True:
|
||||
char = input_stream.read(1)
|
||||
if char in ("\n", "\r"):
|
||||
break
|
||||
if char == "\x03":
|
||||
raise KeyboardInterrupt
|
||||
if char in ("\x7f", "\b"):
|
||||
if password:
|
||||
output_stream.write("\b \b")
|
||||
output_stream.flush()
|
||||
password = password[:-1]
|
||||
elif char == "\x04":
|
||||
if eof_pressed:
|
||||
break
|
||||
eof_pressed = True
|
||||
elif char != "\x00":
|
||||
password += char
|
||||
output_stream.write("*")
|
||||
output_stream.flush()
|
||||
eof_pressed = False
|
||||
return password
|
||||
@@ -0,0 +1,299 @@
|
||||
# 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
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from tld import get_tld
|
||||
@@ -373,6 +374,10 @@ class URL:
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
parsed_url = urlparse(self.url if "://" in self.url else f"//{self.url}")
|
||||
if self.domain.lower() == "goo.gl" and parsed_url.path.startswith("/maps/"):
|
||||
return False
|
||||
|
||||
if self.domain.lower() in SHORTENER_DOMAINS:
|
||||
self.is_shortened = True
|
||||
|
||||
|
||||
@@ -239,6 +239,13 @@ def init_logging(verbose: bool = False):
|
||||
"""
|
||||
log = logging.getLogger("mvt")
|
||||
log.setLevel(logging.DEBUG)
|
||||
|
||||
# Importing an MVT CLI module calls init_logging() at import time, and
|
||||
# loaded module packages may import one indirectly. Keep this idempotent
|
||||
# so console log lines are not duplicated by a second handler.
|
||||
if any(isinstance(handler, MVTLogHandler) for handler in log.handlers):
|
||||
return
|
||||
|
||||
consoleHandler = MVTLogHandler()
|
||||
consoleHandler.setFormatter(logging.Formatter("%(message)s"))
|
||||
if verbose:
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
MVT_VERSION = "2026.5.12"
|
||||
MVT_VERSION = "2026.7.29"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VTNoKey(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VTQuotaExceeded(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def virustotal_lookup(file_hash: str) -> Optional[dict[str, Any]]:
|
||||
if not settings.VT_API_KEY:
|
||||
raise VTNoKey(
|
||||
"No VirusTotal API key provided: to use VirusTotal lookups please set "
|
||||
"MVT_VT_API_KEY or VT_API_KEY in the MVT configuration file"
|
||||
)
|
||||
|
||||
headers = {
|
||||
"User-Agent": "VirusTotal",
|
||||
"Content-Type": "application/json",
|
||||
"x-apikey": settings.VT_API_KEY,
|
||||
}
|
||||
res = requests.get(
|
||||
f"https://www.virustotal.com/api/v3/files/{file_hash}",
|
||||
headers=headers,
|
||||
timeout=settings.NETWORK_TIMEOUT,
|
||||
)
|
||||
|
||||
if res.status_code == 200:
|
||||
report = res.json()
|
||||
return report["data"]
|
||||
|
||||
if res.status_code == 404:
|
||||
log.info("Could not find results for file with hash %s", file_hash)
|
||||
elif res.status_code == 429:
|
||||
raise VTQuotaExceeded("You have exceeded the quota for your VirusTotal API key")
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected response from VirusTotal: {res.status_code}")
|
||||
|
||||
return None
|
||||
+200
-12
@@ -8,9 +8,19 @@ import logging
|
||||
import os
|
||||
|
||||
import click
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from mvt.common.cli_plugins import (
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
load_cli_commands_option,
|
||||
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.updates import IndicatorsUpdates
|
||||
@@ -31,17 +41,23 @@ from mvt.common.help import (
|
||||
HELP_MSG_OUTPUT,
|
||||
HELP_MSG_FAST,
|
||||
HELP_MSG_LIST_MODULES,
|
||||
HELP_MSG_LOAD_MODULE,
|
||||
HELP_MSG_MODULE,
|
||||
HELP_MSG_VERBOSE,
|
||||
HELP_MSG_CHECK_FS,
|
||||
HELP_MSG_CHECK_IOCS,
|
||||
HELP_MSG_STIX2,
|
||||
HELP_MSG_CHECK_IOS_BACKUP,
|
||||
HELP_MSG_CHECK_SYSDIAGNOSE,
|
||||
HELP_MSG_DISABLE_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 .cmd_check_backup import CmdIOSCheckBackup
|
||||
from .cmd_check_fs import CmdIOSCheckFS
|
||||
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
|
||||
from .decrypt import DecryptBackup
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
from .modules.fs import FS_MODULES
|
||||
@@ -65,10 +81,18 @@ def _get_disable_flags(ctx):
|
||||
)
|
||||
|
||||
|
||||
def _load_custom_modules(load_module):
|
||||
try:
|
||||
return load_custom_modules(load_module)
|
||||
except CustomModuleLoadError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Main
|
||||
# ==============================================================================
|
||||
@click.group(invoke_without_command=False)
|
||||
@load_cli_commands_option
|
||||
@click.option(
|
||||
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
|
||||
)
|
||||
@@ -82,10 +106,11 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["disable_version_check"] = disable_update_check
|
||||
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
if ctx.invoked_subcommand != "completion":
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -96,6 +121,40 @@ def version():
|
||||
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
|
||||
# ==============================================================================
|
||||
@@ -150,7 +209,7 @@ def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
|
||||
log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD)
|
||||
backup.decrypt_with_password(os.environ[MVT_IOS_BACKUP_PASSWORD])
|
||||
else:
|
||||
sekrit = Prompt.ask("Enter backup password", password=True)
|
||||
sekrit = prompt_password("Enter backup password: ")
|
||||
backup.decrypt_with_password(sekrit)
|
||||
|
||||
if not backup.can_process():
|
||||
@@ -202,7 +261,7 @@ def extract_key(password, key_file, backup_path):
|
||||
log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD)
|
||||
password = os.environ[MVT_IOS_BACKUP_PASSWORD]
|
||||
else:
|
||||
password = Prompt.ask("Enter backup password", password=True)
|
||||
password = prompt_password("Enter backup password: ")
|
||||
|
||||
backup.decrypt_with_password(password)
|
||||
backup.get_key()
|
||||
@@ -229,15 +288,32 @@ def extract_key(password, key_file, backup_path):
|
||||
@click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_backup(
|
||||
ctx, iocs, output, fast, list_modules, module, hashes, verbose, backup_path
|
||||
ctx,
|
||||
iocs,
|
||||
output,
|
||||
fast,
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
hashes,
|
||||
verbose,
|
||||
backup_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
module_options = {"fast_mode": fast}
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
cmd = CmdIOSCheckBackup(
|
||||
target_path=backup_path,
|
||||
@@ -248,13 +324,17 @@ def check_backup(
|
||||
hashes=hashes,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking iTunes backup located at: %s", backup_path)
|
||||
if not cmd.resolve_backup_path():
|
||||
ctx.exit(1)
|
||||
|
||||
log.info("Checking iTunes backup located at: %s", cmd.target_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
@@ -277,13 +357,32 @@ def check_backup(
|
||||
@click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.argument("DUMP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dump_path):
|
||||
def check_fs(
|
||||
ctx,
|
||||
iocs,
|
||||
output,
|
||||
fast,
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
hashes,
|
||||
verbose,
|
||||
dump_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
module_options = {"fast_mode": fast}
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
cmd = CmdIOSCheckFS(
|
||||
target_path=dump_path,
|
||||
@@ -294,6 +393,7 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
|
||||
hashes=hashes,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -307,6 +407,77 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
|
||||
cmd.show_support_message()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: check-sysdiagnose
|
||||
# ==============================================================================
|
||||
@cli.command(
|
||||
"check-sysdiagnose",
|
||||
context_settings=CONTEXT_SETTINGS,
|
||||
help=HELP_MSG_CHECK_SYSDIAGNOSE,
|
||||
)
|
||||
@click.option(
|
||||
"--iocs",
|
||||
"-i",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_IOC,
|
||||
)
|
||||
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_sysdiagnose(
|
||||
ctx,
|
||||
iocs,
|
||||
output,
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
hashes,
|
||||
verbose,
|
||||
sysdiagnose_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdIOSCheckSysdiagnose(
|
||||
target_path=sysdiagnose_path,
|
||||
results_path=output,
|
||||
ioc_files=iocs,
|
||||
module_name=module,
|
||||
hashes=hashes,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
if not cmd._available_modules():
|
||||
raise click.ClickException(
|
||||
"No custom modules support mvt-ios check-sysdiagnose. "
|
||||
"Load a module that declares supported_commands = "
|
||||
"((\"ios\", \"check-sysdiagnose\"),)."
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path)
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: check-iocs
|
||||
# ==============================================================================
|
||||
@@ -321,15 +492,25 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
|
||||
)
|
||||
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
|
||||
@click.option("--module", "-m", help=HELP_MSG_MODULE)
|
||||
@click.option(
|
||||
"--load-module",
|
||||
type=click.Path(exists=True),
|
||||
multiple=True,
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.argument("FOLDER", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_iocs(ctx, iocs, list_modules, module, folder):
|
||||
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdCheckIOCS(
|
||||
target_path=folder,
|
||||
ioc_files=iocs,
|
||||
module_name=module,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
platform="ios",
|
||||
)
|
||||
cmd.modules = BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
|
||||
@@ -349,3 +530,10 @@ def check_iocs(ctx, iocs, list_modules, module, folder):
|
||||
def download_iocs():
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
ioc_updates.update()
|
||||
|
||||
|
||||
register_cli_plugins(
|
||||
cli,
|
||||
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
@@ -4,10 +4,12 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
from .modules.mixed import MIXED_MODULES
|
||||
@@ -15,6 +17,12 @@ from .modules.mixed import MIXED_MODULES
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_ios_backup_folder(path: str) -> bool:
|
||||
return os.path.isfile(os.path.join(path, "Manifest.db")) and os.path.isfile(
|
||||
os.path.join(path, "Info.plist")
|
||||
)
|
||||
|
||||
|
||||
class CmdIOSCheckBackup(Command):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -29,6 +37,7 @@ class CmdIOSCheckBackup(Command):
|
||||
sub_command: bool = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -43,10 +52,53 @@ class CmdIOSCheckBackup(Command):
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
self.platform = "ios"
|
||||
self.name = "check-backup"
|
||||
self.modules = BACKUP_MODULES + MIXED_MODULES
|
||||
|
||||
def resolve_backup_path(self) -> bool:
|
||||
target_path = getattr(self, "target_path", None)
|
||||
if not isinstance(target_path, str) or not target_path:
|
||||
return False
|
||||
|
||||
if is_ios_backup_folder(target_path):
|
||||
return True
|
||||
|
||||
if not os.path.isdir(target_path):
|
||||
self.log.critical(
|
||||
"%s does not appear to be an iTunes backup folder. "
|
||||
"Expected Manifest.db and Info.plist.",
|
||||
target_path,
|
||||
)
|
||||
return False
|
||||
|
||||
candidates = []
|
||||
for entry_name in sorted(os.listdir(target_path)):
|
||||
entry_path = os.path.join(target_path, entry_name)
|
||||
if os.path.isdir(entry_path) and is_ios_backup_folder(entry_path):
|
||||
candidates.append(entry_path)
|
||||
|
||||
if len(candidates) == 1:
|
||||
self.log.info("Found iTunes backup in subfolder: %s", candidates[0])
|
||||
self.target_path = candidates[0]
|
||||
return True
|
||||
|
||||
if candidates:
|
||||
self.log.critical(
|
||||
"Found multiple iTunes backups in %s. Please specify one backup folder.",
|
||||
target_path,
|
||||
)
|
||||
return False
|
||||
|
||||
self.log.critical(
|
||||
"%s does not appear to be an iTunes backup folder. "
|
||||
"Expected Manifest.db and Info.plist.",
|
||||
target_path,
|
||||
)
|
||||
return False
|
||||
|
||||
def module_init(self, module):
|
||||
module.is_backup = True
|
||||
@@ -8,6 +8,7 @@ from typing import Optional
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.fs import FS_MODULES
|
||||
from .modules.mixed import MIXED_MODULES
|
||||
@@ -29,6 +30,7 @@ class CmdIOSCheckFS(Command):
|
||||
sub_command: bool = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -42,8 +44,10 @@ class CmdIOSCheckFS(Command):
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
self.platform = "ios"
|
||||
self.name = "check-fs"
|
||||
self.modules = FS_MODULES + MIXED_MODULES
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Optional
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdIOSCheckSysdiagnose(Command):
|
||||
def __init__(
|
||||
self,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
ioc_files: Optional[list] = None,
|
||||
iocs: Optional[Indicators] = None,
|
||||
module_name: Optional[str] = None,
|
||||
serial: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
hashes: bool = False,
|
||||
sub_command: bool = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
ioc_files=ioc_files,
|
||||
iocs=iocs,
|
||||
module_name=module_name,
|
||||
serial=serial,
|
||||
module_options=module_options,
|
||||
hashes=hashes,
|
||||
sub_command=sub_command,
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
self.platform = "ios"
|
||||
self.name = "check-sysdiagnose"
|
||||
self.sysdiagnose_format: Optional[str] = None
|
||||
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
|
||||
self.sysdiagnose_files: list[str] = []
|
||||
self.ips_files: list[dict[str, Any]] = []
|
||||
self.temp_sysdiagnose_dir: Optional[TemporaryDirectory[str]] = None
|
||||
self.extracted_sysdiagnose_path: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_bugtype_header(data: bytes) -> Optional[int]:
|
||||
try:
|
||||
header = json.loads(data.split(b"\n", 1)[0].decode("utf-8"))
|
||||
return int(header["bug_type"])
|
||||
except (json.JSONDecodeError, KeyError, UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
def _add_ips_file(self, file_path: str, data: bytes) -> None:
|
||||
bug_type = self._parse_bugtype_header(data)
|
||||
if bug_type is not None:
|
||||
self.ips_files.append({"file_path": file_path, "bug_type": bug_type})
|
||||
|
||||
def init(self) -> None:
|
||||
if not self.target_path:
|
||||
raise ValueError("A sysdiagnose path is required")
|
||||
|
||||
if os.path.isdir(self.target_path):
|
||||
self.sysdiagnose_format = "dir"
|
||||
parent_path = Path(self.target_path).absolute().parent
|
||||
for root, _, filenames in os.walk(self.target_path):
|
||||
for filename in filenames:
|
||||
absolute_path = os.path.join(root, filename)
|
||||
file_path = os.path.relpath(absolute_path, parent_path)
|
||||
self.sysdiagnose_files.append(file_path)
|
||||
if filename.endswith(".ips"):
|
||||
with open(absolute_path, "rb") as handle:
|
||||
self._add_ips_file(absolute_path, handle.read())
|
||||
return
|
||||
|
||||
if not os.path.isfile(self.target_path):
|
||||
raise ValueError(f"Sysdiagnose path does not exist: {self.target_path}")
|
||||
|
||||
self.log.info("Parsing sysdiagnose archive. This might take a while...")
|
||||
self.sysdiagnose_format = "tar"
|
||||
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
|
||||
self._extract_sysdiagnose_archive()
|
||||
|
||||
def _extract_sysdiagnose_archive(self) -> None:
|
||||
archive = self.sysdiagnose_archive
|
||||
if archive is None:
|
||||
raise RuntimeError("Sysdiagnose archive has not been initialized")
|
||||
|
||||
self.temp_sysdiagnose_dir = TemporaryDirectory()
|
||||
extraction_root = Path(self.temp_sysdiagnose_dir.name).resolve()
|
||||
archive_roots = set()
|
||||
|
||||
for member in archive:
|
||||
member_path = PurePosixPath(member.name.replace("\\", "/"))
|
||||
if member_path.is_absolute() or ".." in member_path.parts:
|
||||
self.log.warning("Skipping unsafe sysdiagnose path %r", member.name)
|
||||
continue
|
||||
|
||||
destination = extraction_root.joinpath(*member_path.parts).resolve()
|
||||
if not destination.is_relative_to(extraction_root):
|
||||
self.log.warning("Skipping unsafe sysdiagnose path %r", member.name)
|
||||
continue
|
||||
|
||||
if not member_path.parts:
|
||||
continue
|
||||
archive_roots.add(member_path.parts[0])
|
||||
|
||||
if member.isdir():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
|
||||
# Modules only need directories and regular files. Do not materialize
|
||||
# links or device nodes from an untrusted sysdiagnose archive.
|
||||
if not member.isfile():
|
||||
self.log.warning("Skipping unsafe sysdiagnose member %r", member.name)
|
||||
continue
|
||||
|
||||
normalized_name = member_path.as_posix()
|
||||
self.sysdiagnose_files.append(normalized_name)
|
||||
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
continue
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source, destination.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
if normalized_name.endswith(".ips"):
|
||||
self._add_ips_file(str(destination), destination.read_bytes())
|
||||
|
||||
if len(archive_roots) != 1:
|
||||
raise ValueError("Sysdiagnose archive must contain one top-level directory")
|
||||
|
||||
self.extracted_sysdiagnose_path = str(extraction_root / archive_roots.pop())
|
||||
|
||||
def module_init(self, module) -> None:
|
||||
module.ips_files = self.ips_files
|
||||
if self.sysdiagnose_format == "tar":
|
||||
if self.extracted_sysdiagnose_path is None:
|
||||
raise RuntimeError("Sysdiagnose archive has not been extracted")
|
||||
module.from_sysdiagnose_folder(
|
||||
self.extracted_sysdiagnose_path, self.sysdiagnose_files
|
||||
)
|
||||
return
|
||||
if self.sysdiagnose_format == "dir" and self.target_path:
|
||||
module.from_sysdiagnose_folder(self.target_path, self.sysdiagnose_files)
|
||||
return
|
||||
raise RuntimeError("Sysdiagnose input has not been initialized")
|
||||
|
||||
def finish(self) -> None:
|
||||
if self.sysdiagnose_archive is not None:
|
||||
self.sysdiagnose_archive.close()
|
||||
self.sysdiagnose_archive = None
|
||||
if self.temp_sysdiagnose_dir is not None:
|
||||
self.temp_sysdiagnose_dir.cleanup()
|
||||
self.temp_sysdiagnose_dir = None
|
||||
@@ -1216,6 +1216,14 @@
|
||||
"version": "18.7.9",
|
||||
"build": "22H355"
|
||||
},
|
||||
{
|
||||
"version": "18.7.10",
|
||||
"build": "22H373"
|
||||
},
|
||||
{
|
||||
"version": "18.7.10",
|
||||
"build": "22H374"
|
||||
},
|
||||
{
|
||||
"version": "26",
|
||||
"build": "23A341"
|
||||
@@ -1255,5 +1263,13 @@
|
||||
{
|
||||
"version": "26.5",
|
||||
"build": "23F77"
|
||||
},
|
||||
{
|
||||
"version": "26.6.1",
|
||||
"build": "23G82"
|
||||
},
|
||||
{
|
||||
"version": "26.6.1",
|
||||
"build": "23G83"
|
||||
}
|
||||
]
|
||||
+63
-20
@@ -9,6 +9,7 @@ import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional, Union
|
||||
|
||||
@@ -20,6 +21,20 @@ from mvt.common.module import (
|
||||
)
|
||||
|
||||
|
||||
class TemporarySQLiteConnection(sqlite3.Connection):
|
||||
"""SQLite connection that owns a temporary copy of a database."""
|
||||
|
||||
temporary_directory: Optional[tempfile.TemporaryDirectory] = None
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
super().close()
|
||||
finally:
|
||||
if self.temporary_directory:
|
||||
self.temporary_directory.cleanup()
|
||||
self.temporary_directory = None
|
||||
|
||||
|
||||
class IOSExtraction(MVTModule):
|
||||
"""This class provides a base for all iOS filesystem/backup extraction
|
||||
modules."""
|
||||
@@ -44,6 +59,8 @@ class IOSExtraction(MVTModule):
|
||||
|
||||
self.is_backup = False
|
||||
self.is_fs_dump = False
|
||||
self._recovered_sqlite_paths: dict[str, str] = {}
|
||||
self._sqlite_temp_directories: list[tempfile.TemporaryDirectory] = []
|
||||
|
||||
def _recover_sqlite_db_if_needed(
|
||||
self, file_path: str, forced: bool = False
|
||||
@@ -53,17 +70,12 @@ class IOSExtraction(MVTModule):
|
||||
:param file_path: Path to the malformed database file.
|
||||
|
||||
"""
|
||||
# SQLite's immutable mode cannot open databases with active WAL files.
|
||||
if not forced:
|
||||
# If the database is open, do not use immutable
|
||||
if os.path.isfile(file_path + "-shm"):
|
||||
conn = sqlite3.connect(file_path)
|
||||
else:
|
||||
conn = self._open_sqlite_db(file_path)
|
||||
conn = self._open_sqlite_db(file_path)
|
||||
cur = conn.cursor()
|
||||
|
||||
recover = False
|
||||
try:
|
||||
recover = False
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
except sqlite3.DatabaseError as exc:
|
||||
if "database disk image is malformed" in str(exc):
|
||||
@@ -82,27 +94,54 @@ class IOSExtraction(MVTModule):
|
||||
raise DatabaseCorruptedError(
|
||||
"failed to recover without sqlite3 binary: please install sqlite3!"
|
||||
)
|
||||
if '"' in file_path:
|
||||
raise DatabaseCorruptedError(
|
||||
f"database at path '{file_path}' is corrupted. unable to "
|
||||
'recover because it has a quotation mark (") in its name'
|
||||
)
|
||||
|
||||
bak_path = f"{file_path}.bak"
|
||||
shutil.move(file_path, bak_path)
|
||||
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_recover_")
|
||||
temporary_path = Path(temporary_directory.name)
|
||||
source_path = temporary_path / "source.db"
|
||||
recovered_path = temporary_path / "recovered.db"
|
||||
shutil.copy2(file_path, source_path)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = Path(file_path + suffix)
|
||||
if sidecar.is_file():
|
||||
shutil.copy2(sidecar, Path(str(source_path) + suffix))
|
||||
|
||||
ret = subprocess.call(
|
||||
["sqlite3", bak_path, f'.clone "{file_path}"'],
|
||||
["sqlite3", str(source_path), f'.clone "{recovered_path}"'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if ret != 0:
|
||||
temporary_directory.cleanup()
|
||||
raise DatabaseCorruptedError("failed to recover database")
|
||||
|
||||
self._sqlite_temp_directories.append(temporary_directory)
|
||||
self._recovered_sqlite_paths[file_path] = str(recovered_path)
|
||||
self.log.info("Database at path %s recovered successfully!", file_path)
|
||||
|
||||
def _open_sqlite_db(self, file_path: str) -> sqlite3.Connection:
|
||||
return sqlite3.connect(f"file:{file_path}?immutable=1", uri=True)
|
||||
database_path = self._recovered_sqlite_paths.get(file_path, file_path)
|
||||
if not os.path.isfile(database_path + "-wal"):
|
||||
uri = Path(database_path).resolve().as_uri() + "?mode=ro&immutable=1"
|
||||
return sqlite3.connect(uri, uri=True)
|
||||
|
||||
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_")
|
||||
temporary_path = Path(temporary_directory.name) / Path(database_path).name
|
||||
shutil.copy2(database_path, temporary_path)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = Path(database_path + suffix)
|
||||
if sidecar.is_file():
|
||||
shutil.copy2(sidecar, Path(str(temporary_path) + suffix))
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(
|
||||
temporary_path.resolve().as_uri() + "?mode=ro",
|
||||
uri=True,
|
||||
factory=TemporarySQLiteConnection,
|
||||
)
|
||||
except Exception:
|
||||
temporary_directory.cleanup()
|
||||
raise
|
||||
conn.temporary_directory = temporary_directory
|
||||
return conn
|
||||
|
||||
def _get_backup_files_from_manifest(
|
||||
self, relative_path: Optional[str] = None, domain: Optional[str] = None
|
||||
@@ -166,7 +205,11 @@ class IOSExtraction(MVTModule):
|
||||
if not self.target_path:
|
||||
return None
|
||||
file_path = os.path.join(self.target_path, file_id[0:2], file_id)
|
||||
if not Path(file_path).resolve().is_relative_to(Path(self.target_path).resolve()):
|
||||
if (
|
||||
not Path(file_path)
|
||||
.resolve()
|
||||
.is_relative_to(Path(self.target_path).resolve())
|
||||
):
|
||||
return None
|
||||
if os.path.exists(file_path):
|
||||
return file_path
|
||||
@@ -197,9 +240,9 @@ class IOSExtraction(MVTModule):
|
||||
:param backup_ids: Default value = None)
|
||||
|
||||
"""
|
||||
file_path: Optional[str] = None
|
||||
file_path: Optional[str] = self.file_path
|
||||
# First we check if the was an explicit file path specified.
|
||||
if not self.file_path:
|
||||
if not file_path:
|
||||
# Type narrowing: we know self.file_path is None here, work with local file_path
|
||||
# If not, we first try with backups.
|
||||
# We construct the path to the file according to the iTunes backup
|
||||
|
||||
@@ -134,6 +134,8 @@ class Analytics(IOSExtraction):
|
||||
isodate = ""
|
||||
data = plistlib.loads(row[1])
|
||||
data["isodate"] = isodate
|
||||
else:
|
||||
continue
|
||||
|
||||
data["artifact"] = artifact
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module import DatabaseNotFoundError
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
@@ -17,6 +18,7 @@ from ..base import IOSExtraction
|
||||
|
||||
SHUTDOWN_LOG_PATH = [
|
||||
"private/var/db/diagnostics/shutdown.log",
|
||||
"private/var/db/diagnostics/shutdown.*.log",
|
||||
]
|
||||
|
||||
|
||||
@@ -132,10 +134,17 @@ class ShutdownLog(IOSExtraction):
|
||||
self.results = sorted(self.results, key=lambda entry: entry["isodate"])
|
||||
|
||||
def run(self) -> None:
|
||||
self._find_ios_database(root_paths=SHUTDOWN_LOG_PATH)
|
||||
self.log.info("Found shutdown log at path: %s", self.file_path)
|
||||
if self.file_path:
|
||||
shutdown_log_paths = [self.file_path]
|
||||
else:
|
||||
shutdown_log_paths = sorted(
|
||||
self._get_fs_files_from_patterns(SHUTDOWN_LOG_PATH)
|
||||
)
|
||||
|
||||
if not self.file_path:
|
||||
return
|
||||
with open(self.file_path, "r", encoding="utf-8") as handle:
|
||||
self.process_shutdownlog(handle.read())
|
||||
if not shutdown_log_paths:
|
||||
raise DatabaseNotFoundError("unable to find any shutdown log files")
|
||||
|
||||
for shutdown_log_path in shutdown_log_paths:
|
||||
self.log.info("Found shutdown log at path: %s", shutdown_log_path)
|
||||
with open(shutdown_log_path, "r", encoding="utf-8") as handle:
|
||||
self.process_shutdownlog(handle.read())
|
||||
@@ -26,6 +26,7 @@ from .tcc import TCC
|
||||
from .webkit_resource_load_statistics import WebkitResourceLoadStatistics
|
||||
from .webkit_session_resource_log import WebkitSessionResourceLog
|
||||
from .whatsapp import Whatsapp
|
||||
from .whatsapp_contacts import WhatsappContacts
|
||||
|
||||
MIXED_MODULES = [
|
||||
Calls,
|
||||
@@ -47,6 +48,7 @@ MIXED_MODULES = [
|
||||
WebkitResourceLoadStatistics,
|
||||
WebkitSessionResourceLog,
|
||||
Whatsapp,
|
||||
WhatsappContacts,
|
||||
Shortcuts,
|
||||
Applications,
|
||||
Calendar,
|
||||
|
||||
@@ -73,7 +73,9 @@ class Calls(IOSExtraction):
|
||||
"duration": row[1],
|
||||
"location": row[2],
|
||||
"number": (
|
||||
row[3].decode("utf-8") if row[3] and row[3] is bytes else row[3]
|
||||
row[3].decode("utf-8")
|
||||
if isinstance(row[3], bytes)
|
||||
else row[3]
|
||||
),
|
||||
"provider": row[4],
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
@@ -15,6 +17,7 @@ from mvt.common.module_types import (
|
||||
from mvt.common.utils import convert_mactime_to_iso
|
||||
|
||||
from ..base import IOSExtraction
|
||||
from .whatsapp_contacts import WhatsappContacts
|
||||
|
||||
INTERACTIONC_BACKUP_IDS = [
|
||||
"1f5a521220a3ad80ebfdc196978df8e7a2e49dee",
|
||||
@@ -22,6 +25,49 @@ INTERACTIONC_BACKUP_IDS = [
|
||||
INTERACTIONC_ROOT_PATHS = [
|
||||
"private/var/mobile/Library/CoreDuet/People/interactionC.db",
|
||||
]
|
||||
|
||||
# The interaction record's creation date normally trails its start date by
|
||||
# milliseconds: emitting it as a timeline event only duplicates the start
|
||||
# date event. A large divergence, however, indicates the record was
|
||||
# backfilled (sync, restore, or tampering) and is worth surfacing.
|
||||
CREATION_DATE_DIVERGENCE_THRESHOLD = 3600.0
|
||||
|
||||
# Per-contact aggregate dates from ZCONTACTS are repeated on every
|
||||
# interaction row of the same contact. They are serialized with a
|
||||
# contact-centric data string so that timeline de-duplication collapses
|
||||
# them into one event per contact.
|
||||
CONTACT_EVENT_TEMPLATES = {
|
||||
"contacts_creation_date": "Contact {party} first recorded in interactionC",
|
||||
"first_incoming_sender_date": "First incoming interaction from {party}",
|
||||
"last_incoming_sender_date": "Last incoming interaction from {party}",
|
||||
"first_incoming_recipient_date": (
|
||||
"First incoming interaction where {party} was a recipient"
|
||||
),
|
||||
"last_incoming_recipient_date": (
|
||||
"Last incoming interaction where {party} was a recipient"
|
||||
),
|
||||
"first_outgoing_recipient_date": (
|
||||
"First outgoing interaction to {party}"
|
||||
),
|
||||
"last_outgoing_recipient_date": (
|
||||
"Last outgoing interaction to {party}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _parse_iso(timestamp) -> Optional[datetime.datetime]:
|
||||
try:
|
||||
return datetime.datetime.strptime(
|
||||
timestamp, "%Y-%m-%d %H:%M:%S.%f"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _describe_delta(seconds: float) -> str:
|
||||
if seconds >= 86400:
|
||||
return f"{seconds / 86400:.0f} days"
|
||||
return f"{seconds / 3600:.0f} hours"
|
||||
# Taken from APOLLO
|
||||
# https://github.com/mac4n6/APOLLO/blob/master/modules/interaction_contact_interactions.txt
|
||||
QUERIES = [
|
||||
@@ -34,7 +80,7 @@ QUERIES = [
|
||||
CASE ZINTERACTIONS.ZDIRECTION
|
||||
WHEN '0' THEN 'INCOMING'
|
||||
WHEN '1' THEN 'OUTGOING'
|
||||
END 'DIRECTION' AS "direction",
|
||||
END AS "direction",
|
||||
ZCONTACTS.ZDISPLAYNAME AS "sender_display_name",
|
||||
ZCONTACTS.ZIDENTIFIER AS "sender_identifier",
|
||||
ZCONTACTS.ZPERSONID AS "sender_personid",
|
||||
@@ -89,7 +135,7 @@ QUERIES = [
|
||||
CASE ZINTERACTIONS.ZDIRECTION
|
||||
WHEN '0' THEN 'INCOMING'
|
||||
WHEN '1' THEN 'OUTGOING'
|
||||
END 'DIRECTION' AS "direction",
|
||||
END AS "direction",
|
||||
ZCONTACTS.ZDISPLAYNAME AS "sender_display_name",
|
||||
ZCONTACTS.ZIDENTIFIER AS "sender_identifier",
|
||||
ZCONTACTS.ZPERSONID AS "sender_personid",
|
||||
@@ -117,7 +163,7 @@ QUERIES = [
|
||||
CASE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE
|
||||
WHEN '0' THEN '0'
|
||||
ELSE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE
|
||||
END 'LAST INCOMING RECIPIENT DATE' AS "last_incoming_recipient_date",
|
||||
END AS "last_incoming_recipient_date",
|
||||
ZCONTACTS.ZLASTOUTGOINGRECIPIENTDATE AS "last_outgoing_recipient_date",
|
||||
ZCONTACTS.ZCUSTOMIDENTIFIER AS "custom_id",
|
||||
ZINTERACTIONS.ZCONTENTURL AS "interaction_content_url",
|
||||
@@ -218,9 +264,16 @@ QUERIES = [
|
||||
]
|
||||
|
||||
|
||||
WHATSAPP_BUNDLE_ID = "net.whatsapp.WhatsApp"
|
||||
|
||||
|
||||
class InteractionC(IOSExtraction):
|
||||
"""This module extracts data from InteractionC db."""
|
||||
|
||||
# WhatsApp identifies chat peers by LID in interactionC.db, which only the
|
||||
# WhatsApp contacts database can map back to a phone number and name.
|
||||
dependencies = [WhatsappContacts]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
@@ -252,10 +305,51 @@ class InteractionC(IOSExtraction):
|
||||
"last_outgoing_recipient_date",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _describe_party(record: ModuleAtomicResult, prefix: str) -> Optional[str]:
|
||||
name = record.get(f"{prefix}_display_name") or record.get(
|
||||
f"{prefix}_resolved_name"
|
||||
)
|
||||
identifier = record.get(f"{prefix}_resolved_phone_number") or record.get(
|
||||
f"{prefix}_identifier"
|
||||
)
|
||||
if name and identifier:
|
||||
# A display name that is just a formatted copy of the phone
|
||||
# number adds no information.
|
||||
name_digits = re.sub(r"\D", "", name)
|
||||
if name_digits and name_digits == re.sub(r"\D", "", identifier):
|
||||
return identifier
|
||||
return f"{name} ({identifier})"
|
||||
return name or identifier or None
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
sender = self._describe_party(record, "sender")
|
||||
# The chat peer from the domain identifier stands in when the
|
||||
# recipient was not recorded (or the recipient join is unavailable).
|
||||
recipient = self._describe_party(record, "recipient") or self._describe_party(
|
||||
record, "domain"
|
||||
)
|
||||
direction = record.get("direction")
|
||||
if not sender and direction == "OUTGOING":
|
||||
sender = "local user"
|
||||
if not recipient and direction == "INCOMING":
|
||||
recipient = "local user"
|
||||
|
||||
header = f"[{record['bundle_id']}]"
|
||||
if record.get("account"):
|
||||
header += f" {record['account']}"
|
||||
if direction:
|
||||
header += f" {direction}"
|
||||
|
||||
data = f"{header} from {sender or 'unknown'} to {recipient or 'unknown'}"
|
||||
if record.get("group_name"):
|
||||
data += f" (group: {record['group_name']})"
|
||||
if record.get("content"):
|
||||
data += f": {record['content']}"
|
||||
|
||||
records = []
|
||||
processed = []
|
||||
for timestamp in self.timestamps:
|
||||
for timestamp in ("start_date", "end_date"):
|
||||
# Check if the record has the current timestamp.
|
||||
if timestamp not in record or not record[timestamp]:
|
||||
continue
|
||||
@@ -269,16 +363,142 @@ class InteractionC(IOSExtraction):
|
||||
"timestamp": record[timestamp],
|
||||
"module": self.__class__.__name__,
|
||||
"event": timestamp,
|
||||
"data": f"[{record['bundle_id']}] {record['account']} - "
|
||||
f"from {record['sender_display_name']} ({record['sender_identifier']}) "
|
||||
f"to {record.get('recipient_display_name', '')} ({record.get('recipient_identifier', '')}):"
|
||||
f" {record.get('content', '')}",
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
processed.append(record[timestamp])
|
||||
|
||||
creation_event = self._serialize_creation_date(record, data)
|
||||
if creation_event:
|
||||
records.append(creation_event)
|
||||
|
||||
# Contact-level aggregates describe the sender's contact record.
|
||||
party = self._describe_party(record, "sender")
|
||||
if party:
|
||||
for field, template in CONTACT_EVENT_TEMPLATES.items():
|
||||
if not record.get(field):
|
||||
continue
|
||||
records.append(
|
||||
{
|
||||
"timestamp": record[field],
|
||||
"module": self.__class__.__name__,
|
||||
"event": field,
|
||||
"data": template.format(party=party),
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
def _serialize_creation_date(
|
||||
self, record: ModuleAtomicResult, data: str
|
||||
) -> Optional[dict]:
|
||||
"""Serialize the interaction record's creation date only when it
|
||||
diverges from the start date enough to indicate the record was
|
||||
backfilled."""
|
||||
creation = record.get("interactions_creation_date")
|
||||
if not creation:
|
||||
return None
|
||||
|
||||
event = {
|
||||
"timestamp": creation,
|
||||
"module": self.__class__.__name__,
|
||||
"event": "interactions_creation_date",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
start = _parse_iso(record.get("start_date"))
|
||||
creation_parsed = _parse_iso(creation)
|
||||
if not start or not creation_parsed:
|
||||
# Without a start date the creation date is the only anchor.
|
||||
return event
|
||||
|
||||
delta = (creation_parsed - start).total_seconds()
|
||||
if abs(delta) < CREATION_DATE_DIVERGENCE_THRESHOLD:
|
||||
return None
|
||||
|
||||
direction = "after" if delta > 0 else "before"
|
||||
event["data"] = (
|
||||
f"Interaction record created {_describe_delta(abs(delta))} "
|
||||
f"{direction} the event: {data}"
|
||||
)
|
||||
return event
|
||||
|
||||
def _whatsapp_contact_maps(self) -> Tuple[dict, dict]:
|
||||
"""Build LID and phone-digit lookup maps from the WhatsappContacts
|
||||
module results, when available."""
|
||||
by_lid: dict = {}
|
||||
by_phone: dict = {}
|
||||
contacts_module = self.dependency_modules.get(WhatsappContacts)
|
||||
if not contacts_module:
|
||||
return by_lid, by_phone
|
||||
|
||||
for contact in contacts_module.results:
|
||||
name = contact.get("full_name") or contact.get("given_name")
|
||||
phone = contact.get("phone_number")
|
||||
entry = (phone, name)
|
||||
if contact.get("lid"):
|
||||
by_lid[contact["lid"]] = entry
|
||||
if phone:
|
||||
by_phone[re.sub(r"\D", "", phone)] = entry
|
||||
whatsapp_id = contact.get("whatsapp_id")
|
||||
if whatsapp_id and "@" in whatsapp_id:
|
||||
by_phone.setdefault(whatsapp_id.split("@")[0], entry)
|
||||
|
||||
return by_lid, by_phone
|
||||
|
||||
@staticmethod
|
||||
def _resolve_whatsapp_identifier(
|
||||
value, by_lid: dict, by_phone: dict
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Resolve a WhatsApp identifier (LID, JID or phone number) to a
|
||||
(phone_number, contact_name) tuple."""
|
||||
if not value:
|
||||
return None, None
|
||||
|
||||
value = str(value)
|
||||
if value.endswith("@lid"):
|
||||
return by_lid.get(value, (None, None))
|
||||
if value.endswith("@g.us"):
|
||||
return None, None
|
||||
if value.endswith("@s.whatsapp.net"):
|
||||
digits = value.split("@")[0]
|
||||
phone, name = by_phone.get(digits, (None, None))
|
||||
return phone or f"+{digits}", name
|
||||
if value.startswith("+"):
|
||||
_, name = by_phone.get(re.sub(r"\D", "", value), (None, None))
|
||||
return None, name
|
||||
|
||||
return None, None
|
||||
|
||||
def _postprocess_results(self) -> None:
|
||||
by_lid, by_phone = self._whatsapp_contact_maps()
|
||||
|
||||
for entry in self.results:
|
||||
# The fallback queries return ZDIRECTION raw instead of labelled.
|
||||
if entry.get("direction") in (0, "0"):
|
||||
entry["direction"] = "INCOMING"
|
||||
elif entry.get("direction") in (1, "1"):
|
||||
entry["direction"] = "OUTGOING"
|
||||
|
||||
if entry.get("bundle_id") != WHATSAPP_BUNDLE_ID:
|
||||
continue
|
||||
|
||||
candidates = {
|
||||
"sender": [entry.get("sender_identifier"), entry.get("custom_id")],
|
||||
"recipient": [entry.get("recipient_identifier")],
|
||||
"domain": [entry.get("domain_identifier")],
|
||||
}
|
||||
for prefix, values in candidates.items():
|
||||
phone = name = None
|
||||
for value in values:
|
||||
phone, name = self._resolve_whatsapp_identifier(
|
||||
value, by_lid, by_phone
|
||||
)
|
||||
if phone or name:
|
||||
break
|
||||
entry[f"{prefix}_resolved_phone_number"] = phone
|
||||
entry[f"{prefix}_resolved_name"] = name
|
||||
|
||||
def run(self) -> None:
|
||||
self._find_ios_database(
|
||||
backup_ids=INTERACTIONC_BACKUP_IDS, root_paths=INTERACTIONC_ROOT_PATHS
|
||||
@@ -325,4 +545,6 @@ class InteractionC(IOSExtraction):
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
self._postprocess_results()
|
||||
|
||||
self.log.info("Extracted a total of %d InteractionC events", len(self.results))
|
||||
@@ -19,10 +19,17 @@ from mvt.common.utils import convert_mactime_to_iso, keys_bytes_to_string
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
SAFARI_BROWSER_STATE_BACKUP_RELPATH = "Library/Safari/BrowserState.db"
|
||||
# Safari profiles (iOS 17 and later) keep their per-profile databases under
|
||||
# Library/Safari/Profiles/<UUID>/, separate from the default profile's.
|
||||
SAFARI_BROWSER_STATE_BACKUP_RELPATHS = [
|
||||
"Library/Safari/BrowserState.db",
|
||||
"Library/Safari/Profiles/*/BrowserState.db",
|
||||
]
|
||||
SAFARI_BROWSER_STATE_ROOT_PATHS = [
|
||||
"private/var/mobile/Library/Safari/BrowserState.db",
|
||||
"private/var/mobile/Library/Safari/Profiles/*/BrowserState.db",
|
||||
"private/var/mobile/Containers/Data/Application/*/Library/Safari/BrowserState.db",
|
||||
"private/var/mobile/Containers/Data/Application/*/Library/Safari/Profiles/*/BrowserState.db",
|
||||
]
|
||||
|
||||
|
||||
@@ -174,20 +181,22 @@ class SafariBrowserState(IOSExtraction):
|
||||
|
||||
def run(self) -> None:
|
||||
if self.is_backup:
|
||||
for backup_file in self._get_backup_files_from_manifest(
|
||||
relative_path=SAFARI_BROWSER_STATE_BACKUP_RELPATH
|
||||
):
|
||||
browserstate_path = self._get_backup_file_from_id(
|
||||
backup_file["file_id"]
|
||||
)
|
||||
for relative_path in SAFARI_BROWSER_STATE_BACKUP_RELPATHS:
|
||||
for backup_file in self._get_backup_files_from_manifest(
|
||||
relative_path=relative_path
|
||||
):
|
||||
browserstate_path = self._get_backup_file_from_id(
|
||||
backup_file["file_id"]
|
||||
)
|
||||
|
||||
if not browserstate_path:
|
||||
continue
|
||||
if not browserstate_path:
|
||||
continue
|
||||
|
||||
self.log.info(
|
||||
"Found Safari browser state database at path: %s", browserstate_path
|
||||
)
|
||||
self._process_browser_state_db(browserstate_path)
|
||||
self.log.info(
|
||||
"Found Safari browser state database at path: %s",
|
||||
browserstate_path,
|
||||
)
|
||||
self._process_browser_state_db(browserstate_path)
|
||||
elif self.is_fs_dump:
|
||||
for browserstate_path in self._get_fs_files_from_patterns(
|
||||
SAFARI_BROWSER_STATE_ROOT_PATHS
|
||||
|
||||
@@ -17,10 +17,17 @@ from mvt.common.utils import convert_mactime_to_datetime, convert_mactime_to_iso
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
SAFARI_HISTORY_BACKUP_RELPATH = "Library/Safari/History.db"
|
||||
# Safari profiles (iOS 17 and later) each keep their own History.db under
|
||||
# Library/Safari/Profiles/<UUID>/, separate from the default profile's database.
|
||||
SAFARI_HISTORY_BACKUP_RELPATHS = [
|
||||
"Library/Safari/History.db",
|
||||
"Library/Safari/Profiles/*/History.db",
|
||||
]
|
||||
SAFARI_HISTORY_ROOT_PATHS = [
|
||||
"private/var/mobile/Library/Safari/History.db",
|
||||
"private/var/mobile/Library/Safari/Profiles/*/History.db",
|
||||
"private/var/mobile/Containers/Data/Application/*/Library/Safari/History.db",
|
||||
"private/var/mobile/Containers/Data/Application/*/Library/Safari/Profiles/*/History.db",
|
||||
]
|
||||
|
||||
|
||||
@@ -75,6 +82,9 @@ class SafariHistory(IOSExtraction):
|
||||
|
||||
# We loop again through visits in order to find redirect record.
|
||||
for redirect in self.results:
|
||||
if redirect["safari_history_db"] != result["safari_history_db"]:
|
||||
continue
|
||||
|
||||
if redirect["visit_id"] != result["redirect_destination"]:
|
||||
continue
|
||||
|
||||
@@ -159,17 +169,22 @@ class SafariHistory(IOSExtraction):
|
||||
|
||||
def run(self) -> None:
|
||||
if self.is_backup:
|
||||
for history_file in self._get_backup_files_from_manifest(
|
||||
relative_path=SAFARI_HISTORY_BACKUP_RELPATH
|
||||
):
|
||||
history_path = self._get_backup_file_from_id(history_file["file_id"])
|
||||
for relative_path in SAFARI_HISTORY_BACKUP_RELPATHS:
|
||||
for history_file in self._get_backup_files_from_manifest(
|
||||
relative_path=relative_path
|
||||
):
|
||||
history_path = self._get_backup_file_from_id(
|
||||
history_file["file_id"]
|
||||
)
|
||||
|
||||
if not history_path:
|
||||
continue
|
||||
if not history_path:
|
||||
continue
|
||||
|
||||
self.log.info("Found Safari history database at path: %s", history_path)
|
||||
self.log.info(
|
||||
"Found Safari history database at path: %s", history_path
|
||||
)
|
||||
|
||||
self._process_history_db(history_path)
|
||||
self._process_history_db(history_path)
|
||||
elif self.is_fs_dump:
|
||||
for history_path in self._get_fs_files_from_patterns(
|
||||
SAFARI_HISTORY_ROOT_PATHS
|
||||
|
||||
@@ -76,8 +76,10 @@ class Shortcuts(IOSExtraction):
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_urls(result["action_urls"])
|
||||
url_batches = [result["action_urls"] for result in self.results]
|
||||
for result, ioc_match in zip(
|
||||
self.results, self.indicators.check_url_batches(url_batches)
|
||||
):
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
|
||||
@@ -85,17 +85,27 @@ class SMS(IOSExtraction):
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
url_batches = []
|
||||
for result in self.results:
|
||||
message_links = result.get("links", [])
|
||||
# Making sure not link was ignored
|
||||
if message_links == []:
|
||||
message_links = check_for_links(result.get("text", ""))
|
||||
ioc_match = self.indicators.check_urls(message_links)
|
||||
url_batches.append(message_links)
|
||||
|
||||
for result, ioc_match in zip(
|
||||
self.results, self.indicators.check_url_batches(url_batches)
|
||||
):
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
def collect_url_results(self) -> None:
|
||||
for message in self.results:
|
||||
for url in message.get("links", []):
|
||||
self.add_url_result(url, message.get("isodate"), "sms")
|
||||
|
||||
def run(self) -> None:
|
||||
self._find_ios_database(backup_ids=SMS_BACKUP_IDS, root_paths=SMS_ROOT_PATHS)
|
||||
self.log.info("Found SMS database at path: %s", self.file_path)
|
||||
|
||||
@@ -70,6 +70,8 @@ class WebkitSessionResourceLog(IOSExtraction):
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
records = []
|
||||
url_batches = []
|
||||
for _, entries in self.results.items():
|
||||
for entry in entries:
|
||||
source_domains = self._extract_domains(entry["redirect_source"])
|
||||
@@ -94,36 +96,42 @@ class WebkitSessionResourceLog(IOSExtraction):
|
||||
)
|
||||
)
|
||||
|
||||
ioc_match = self.indicators.check_urls(all_origins)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", entry, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
records.append((entry, source_domains, destination_domains))
|
||||
url_batches.append(all_origins)
|
||||
|
||||
redirect_path = ""
|
||||
if len(source_domains) > 0:
|
||||
redirect_path += "SOURCE: "
|
||||
for idx, item in enumerate(source_domains):
|
||||
source_domains[idx] = f'"{item}"'
|
||||
for record, ioc_match in zip(
|
||||
records, self.indicators.check_url_batches(url_batches)
|
||||
):
|
||||
if ioc_match:
|
||||
entry, source_domains, destination_domains = record
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", entry, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
redirect_path += ", ".join(source_domains)
|
||||
redirect_path += " -> "
|
||||
redirect_path = ""
|
||||
if len(source_domains) > 0:
|
||||
redirect_path += "SOURCE: "
|
||||
for idx, item in enumerate(source_domains):
|
||||
source_domains[idx] = f'"{item}"'
|
||||
|
||||
redirect_path += f'ORIGIN: "{entry["origin"]}"'
|
||||
redirect_path += ", ".join(source_domains)
|
||||
redirect_path += " -> "
|
||||
|
||||
if len(destination_domains) > 0:
|
||||
redirect_path += " -> "
|
||||
redirect_path += "DESTINATION: "
|
||||
for idx, item in enumerate(destination_domains):
|
||||
destination_domains[idx] = f'"{item}"'
|
||||
redirect_path += f'ORIGIN: "{entry["origin"]}"'
|
||||
|
||||
redirect_path += ", ".join(destination_domains)
|
||||
if len(destination_domains) > 0:
|
||||
redirect_path += " -> "
|
||||
redirect_path += "DESTINATION: "
|
||||
for idx, item in enumerate(destination_domains):
|
||||
destination_domains[idx] = f'"{item}"'
|
||||
|
||||
self.alertstore.high(
|
||||
f"Found HTTP redirect between suspicious domains: {redirect_path}",
|
||||
"",
|
||||
entry,
|
||||
)
|
||||
redirect_path += ", ".join(destination_domains)
|
||||
|
||||
self.alertstore.high(
|
||||
f"Found HTTP redirect between suspicious domains: {redirect_path}",
|
||||
"",
|
||||
entry,
|
||||
)
|
||||
|
||||
def _extract_browsing_stats(self, log_path):
|
||||
items = []
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Dict, Optional
|
||||
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
@@ -22,9 +24,71 @@ WHATSAPP_ROOT_PATHS = [
|
||||
"private/var/mobile/Containers/Shared/AppGroup/*/ChatStorage.sqlite",
|
||||
]
|
||||
|
||||
WHATSAPP_LID_BACKUP_IDS = [
|
||||
# SHA-1 of "AppDomainGroup-group.net.whatsapp.WhatsApp.shared-LID.sqlite"
|
||||
"e794f6ffcc3c222535f47684a63d5178da3c4500",
|
||||
]
|
||||
WHATSAPP_LID_ROOT_PATHS = [
|
||||
"private/var/mobile/Containers/Shared/AppGroup/*/LID.sqlite",
|
||||
]
|
||||
|
||||
# WhatsApp records the mapping between a contact's LID and phone number
|
||||
# identifiers in the ZWAPHONENUMBERLIDPAIR table. Depending on the WhatsApp
|
||||
# version this lives in a dedicated LID.sqlite database or in
|
||||
# ChatStorage.sqlite itself.
|
||||
LID_PAIRS_QUERY = """
|
||||
SELECT
|
||||
ZLID AS "lid",
|
||||
ZPHONENUMBER AS "phone_number",
|
||||
ZTIMESTAMP AS "pair_timestamp"
|
||||
FROM ZWAPHONENUMBERLIDPAIR;
|
||||
"""
|
||||
|
||||
CHAT_SESSIONS_QUERY = """
|
||||
SELECT
|
||||
ZWACHATSESSION.Z_PK AS "session_pk",
|
||||
ZWACHATSESSION.ZCONTACTJID AS "contact_jid",
|
||||
ZWACHATSESSION.ZPARTNERNAME AS "partner_name",
|
||||
ZWACHATSESSION.ZSESSIONTYPE AS "session_type",
|
||||
ZWACHATSESSION.ZARCHIVED AS "archived",
|
||||
ZWACHATSESSION.ZREMOVED AS "removed",
|
||||
ZWACHATSESSION.ZMESSAGECOUNTER AS "message_counter",
|
||||
ZWACHATSESSION.ZLASTMESSAGEDATE AS "last_message_date",
|
||||
ZWAGROUPINFO.ZCREATIONDATE AS "group_creation_date",
|
||||
MIN(ZWAMESSAGE.ZMESSAGEDATE) AS "first_stored_message_date",
|
||||
MAX(ZWAMESSAGE.ZMESSAGEDATE) AS "last_stored_message_date",
|
||||
COUNT(ZWAMESSAGE.Z_PK) AS "stored_message_count"
|
||||
FROM ZWACHATSESSION
|
||||
LEFT JOIN ZWAGROUPINFO
|
||||
ON ZWACHATSESSION.ZGROUPINFO = ZWAGROUPINFO.Z_PK
|
||||
LEFT JOIN ZWAMESSAGE
|
||||
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
|
||||
GROUP BY ZWACHATSESSION.Z_PK;
|
||||
"""
|
||||
|
||||
CHAT_SESSION_DATE_FIELDS = [
|
||||
"last_message_date",
|
||||
"group_creation_date",
|
||||
"first_stored_message_date",
|
||||
"last_stored_message_date",
|
||||
]
|
||||
|
||||
|
||||
def _describe_chat(record: ModuleAtomicResult) -> str:
|
||||
jid = record.get("contact_jid") or "unknown"
|
||||
name = record.get("partner_name")
|
||||
identifier = record.get("partner_resolved_phone_number") or jid
|
||||
label = f"'{name}' ({identifier})" if name else identifier
|
||||
is_group = jid.endswith("@g.us") or record.get("group_creation_date")
|
||||
if is_group:
|
||||
return f"WhatsApp group chat {label}"
|
||||
return f"WhatsApp chat with {label}"
|
||||
|
||||
|
||||
class Whatsapp(IOSExtraction):
|
||||
"""This module extracts all WhatsApp messages containing links."""
|
||||
"""This module extracts all WhatsApp messages containing links, as well
|
||||
as per-chat records with the first and last interaction dates of each
|
||||
conversation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -45,6 +109,21 @@ class Whatsapp(IOSExtraction):
|
||||
)
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
if record.get("record_type") == "chat_session":
|
||||
return self._serialize_chat_session(record)
|
||||
if record.get("record_type") == "lid_phone_number_pair":
|
||||
if not record.get("pair_timestamp"):
|
||||
return []
|
||||
return {
|
||||
"timestamp": record["pair_timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "lid_pair_recorded",
|
||||
"data": (
|
||||
f"WhatsApp associated LID {record.get('lid')} with "
|
||||
f"phone number {record.get('phone_number')}"
|
||||
),
|
||||
}
|
||||
|
||||
text = record.get("ZTEXT", "").replace("\n", "\\n")
|
||||
links_text = ""
|
||||
if record.get("links"):
|
||||
@@ -57,17 +136,67 @@ class Whatsapp(IOSExtraction):
|
||||
"data": f"'{text}' from {record.get('ZFROMJID', 'Unknown')}{links_text}",
|
||||
}
|
||||
|
||||
def _serialize_chat_session(
|
||||
self, record: ModuleAtomicResult
|
||||
) -> ModuleSerializedResult:
|
||||
records = []
|
||||
chat = _describe_chat(record)
|
||||
|
||||
if record.get("group_creation_date"):
|
||||
records.append(
|
||||
{
|
||||
"timestamp": record["group_creation_date"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "group_created",
|
||||
"data": f"{chat} was created",
|
||||
}
|
||||
)
|
||||
|
||||
if record.get("first_stored_message_date"):
|
||||
records.append(
|
||||
{
|
||||
"timestamp": record["first_stored_message_date"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "chat_first_message",
|
||||
"data": f"First stored message in {chat}",
|
||||
}
|
||||
)
|
||||
|
||||
# The chat session's own last-message date is authoritative: it can
|
||||
# postdate the newest stored message if that message was deleted.
|
||||
last_message_date = record.get("last_message_date") or record.get(
|
||||
"last_stored_message_date"
|
||||
)
|
||||
if last_message_date:
|
||||
records.append(
|
||||
{
|
||||
"timestamp": last_message_date,
|
||||
"module": self.__class__.__name__,
|
||||
"event": "chat_last_message",
|
||||
"data": f"Last message in {chat}",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_urls(result.get("links", []))
|
||||
url_batches = [result.get("links", []) for result in self.results]
|
||||
for result, ioc_match in zip(
|
||||
self.results, self.indicators.check_url_batches(url_batches)
|
||||
):
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
def collect_url_results(self) -> None:
|
||||
for message in self.results:
|
||||
for url in message.get("links", []):
|
||||
self.add_url_result(url, message.get("isodate"), "whatsapp")
|
||||
|
||||
def run(self) -> None:
|
||||
self._find_ios_database(
|
||||
backup_ids=WHATSAPP_BACKUP_IDS, root_paths=WHATSAPP_ROOT_PATHS
|
||||
@@ -139,7 +268,126 @@ class Whatsapp(IOSExtraction):
|
||||
message["links"] = list(set(filtered_links))
|
||||
self.results.append(message)
|
||||
|
||||
total_messages = len(self.results)
|
||||
lid_map = self._extract_lid_pairs(cur)
|
||||
total_sessions = self._extract_chat_sessions(cur, lid_map)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
self.log.info("Extracted a total of %d WhatsApp messages", len(self.results))
|
||||
self.log.info(
|
||||
"Extracted a total of %d WhatsApp messages, %d chat sessions "
|
||||
"and %d LID-phone number pairs",
|
||||
total_messages,
|
||||
total_sessions,
|
||||
len(lid_map),
|
||||
)
|
||||
|
||||
def _find_lid_db_path(self) -> Optional[str]:
|
||||
for backup_id in WHATSAPP_LID_BACKUP_IDS:
|
||||
file_path = self._get_backup_file_from_id(backup_id)
|
||||
if file_path and os.path.exists(file_path):
|
||||
return file_path
|
||||
for found_path in self._get_fs_files_from_patterns(
|
||||
WHATSAPP_LID_ROOT_PATHS
|
||||
):
|
||||
return found_path
|
||||
return None
|
||||
|
||||
def _extract_lid_pairs(self, chat_cur: sqlite3.Cursor) -> Dict[str, str]:
|
||||
"""Extract the LID to phone number mapping from the dedicated
|
||||
LID.sqlite database, falling back to the same table in
|
||||
ChatStorage.sqlite. Returns a map of LID digits to phone number
|
||||
digits."""
|
||||
rows = []
|
||||
lid_db_path = self._find_lid_db_path()
|
||||
if lid_db_path:
|
||||
self.log.info(
|
||||
"Found WhatsApp LID database at path: %s", lid_db_path
|
||||
)
|
||||
lid_conn = self._open_sqlite_db(lid_db_path)
|
||||
try:
|
||||
lid_cur = lid_conn.cursor()
|
||||
lid_cur.execute(LID_PAIRS_QUERY)
|
||||
rows = lid_cur.fetchall()
|
||||
lid_cur.close()
|
||||
except sqlite3.DatabaseError as exc:
|
||||
self.log.warning(
|
||||
"Unable to extract WhatsApp LID-phone number pairs: %s",
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
lid_conn.close()
|
||||
else:
|
||||
try:
|
||||
chat_cur.execute(LID_PAIRS_QUERY)
|
||||
rows = chat_cur.fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
self.log.info(
|
||||
"No WhatsApp LID database found in this backup or "
|
||||
"filesystem dump: LID chat identifiers cannot be "
|
||||
"resolved to phone numbers"
|
||||
)
|
||||
|
||||
lid_map: Dict[str, str] = {}
|
||||
for lid, phone_number, pair_timestamp in rows:
|
||||
record = {
|
||||
"record_type": "lid_phone_number_pair",
|
||||
"lid": lid,
|
||||
"phone_number": phone_number,
|
||||
"pair_timestamp": (
|
||||
convert_mactime_to_iso(pair_timestamp) or None
|
||||
)
|
||||
if pair_timestamp
|
||||
else None,
|
||||
}
|
||||
self.results.append(record)
|
||||
|
||||
if lid and phone_number:
|
||||
lid_digits = str(lid).split("@")[0]
|
||||
phone_digits = str(phone_number).split("@")[0].lstrip("+")
|
||||
lid_map[lid_digits] = phone_digits
|
||||
|
||||
return lid_map
|
||||
|
||||
def _extract_chat_sessions(
|
||||
self, cur: sqlite3.Cursor, lid_map: Dict[str, str]
|
||||
) -> int:
|
||||
"""Extract one record per chat session with the first and last
|
||||
interaction dates of each conversation."""
|
||||
try:
|
||||
cur.execute(CHAT_SESSIONS_QUERY)
|
||||
except sqlite3.OperationalError as exc:
|
||||
self.log.warning(
|
||||
"Unable to extract WhatsApp chat sessions: %s", exc
|
||||
)
|
||||
return 0
|
||||
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
total_sessions = 0
|
||||
for row in cur.fetchall():
|
||||
session = dict(zip(names, row))
|
||||
session["record_type"] = "chat_session"
|
||||
|
||||
session["partner_resolved_phone_number"] = None
|
||||
jid = session.get("contact_jid") or ""
|
||||
if jid.endswith("@lid"):
|
||||
phone_digits = lid_map.get(jid.split("@")[0])
|
||||
if phone_digits:
|
||||
session["partner_resolved_phone_number"] = (
|
||||
f"+{phone_digits}"
|
||||
)
|
||||
|
||||
for field in CHAT_SESSION_DATE_FIELDS:
|
||||
if session.get(field):
|
||||
session[field] = (
|
||||
convert_mactime_to_iso(session[field]) or None
|
||||
)
|
||||
else:
|
||||
session[field] = None
|
||||
|
||||
self.results.append(session)
|
||||
total_sessions += 1
|
||||
|
||||
return total_sessions
|
||||
@@ -0,0 +1,308 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module import DatabaseNotFoundError
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import convert_mactime_to_iso
|
||||
|
||||
from ..base import IOSExtraction
|
||||
|
||||
WHATSAPP_CONTACTS_BACKUP_IDS = [
|
||||
# SHA-1 of "AppDomainGroup-group.net.whatsapp.WhatsApp.shared-ContactsV2.sqlite"
|
||||
"b8548dc30aa1030df0ce18ef08b882cf7ab5212f",
|
||||
]
|
||||
WHATSAPP_CONTACTS_ROOT_PATHS = [
|
||||
"private/var/mobile/Containers/Shared/AppGroup/*/ContactsV2.sqlite",
|
||||
]
|
||||
|
||||
# WhatsApp's standard disappearing-messages timer values, in seconds.
|
||||
DISAPPEARING_DURATION_LABELS = {
|
||||
86400: "24 hours",
|
||||
604800: "7 days",
|
||||
1209600: "14 days",
|
||||
2592000: "30 days",
|
||||
7776000: "90 days",
|
||||
}
|
||||
|
||||
# Output field -> candidate columns in ZWAADDRESSBOOKCONTACT, in order of
|
||||
# preference. WhatsApp renames columns across versions, so the query is built
|
||||
# from the columns actually present in the database.
|
||||
COLUMN_CANDIDATES = {
|
||||
"whatsapp_id": ["ZWHATSAPPID"],
|
||||
"lid": ["ZLID"],
|
||||
"phone_number": ["ZPHONENUMBER"],
|
||||
"localized_phone_number": ["ZLOCALIZEDPHONENUMBER"],
|
||||
"full_name": ["ZFULLNAME"],
|
||||
"given_name": ["ZGIVENNAME"],
|
||||
"last_name": ["ZLASTNAME"],
|
||||
"user_name": ["ZUSERNAME"],
|
||||
"business_name": ["ZBUSINESSNAME"],
|
||||
"about_text": ["ZABOUTTEXT"],
|
||||
"about_emoji": ["ZABOUTEMOJI"],
|
||||
"notes": ["ZNOTES"],
|
||||
"disappearing_mode_duration": ["ZDISAPPEARINGMODEDURATION"],
|
||||
"disappearing_mode_timestamp": ["ZDISAPPEARINGMODETIMESTAMP"],
|
||||
"about_timestamp": ["ZABOUTTIMESTAMP"],
|
||||
"about_expiration_timestamp": ["ZABOUTEXPIRATIONTIMESTAMP"],
|
||||
"last_updated": ["ZLASTUPDATED"],
|
||||
"phone_status": ["ZPHONESTATUS", "ZPHONENUMBERSTATUS"],
|
||||
"sync_policy": ["ZSYNCPOLICY"],
|
||||
}
|
||||
|
||||
STRING_FIELDS = [
|
||||
"whatsapp_id",
|
||||
"lid",
|
||||
"phone_number",
|
||||
"localized_phone_number",
|
||||
"full_name",
|
||||
"given_name",
|
||||
"last_name",
|
||||
"user_name",
|
||||
"business_name",
|
||||
"about_text",
|
||||
"about_emoji",
|
||||
"notes",
|
||||
]
|
||||
|
||||
DATE_FIELDS = [
|
||||
"disappearing_mode_timestamp",
|
||||
"about_timestamp",
|
||||
"about_expiration_timestamp",
|
||||
"last_updated",
|
||||
]
|
||||
|
||||
|
||||
def _decode_string(value) -> Optional[str]:
|
||||
# CoreData stores string attributes as UTF-8 blobs in some WhatsApp
|
||||
# versions, so values can arrive as either bytes or str.
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", "replace")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _label_duration(duration) -> str:
|
||||
if not duration:
|
||||
return "off"
|
||||
return DISAPPEARING_DURATION_LABELS.get(
|
||||
int(duration), f"{int(duration)} seconds"
|
||||
)
|
||||
|
||||
|
||||
def _describe_contact(record: ModuleAtomicResult) -> str:
|
||||
contact = (
|
||||
record.get("whatsapp_id")
|
||||
or record.get("lid")
|
||||
or record.get("phone_number")
|
||||
or "unknown"
|
||||
)
|
||||
full_name = record.get("full_name")
|
||||
if full_name:
|
||||
contact = f"{contact} ({full_name})"
|
||||
return contact
|
||||
|
||||
|
||||
class WhatsappContacts(IOSExtraction):
|
||||
"""This module extracts WhatsApp contact records and per-contact
|
||||
disappearing-messages settings from ContactsV2.sqlite.
|
||||
|
||||
ChatStorage.sqlite does not record the disappearing-messages state of 1:1
|
||||
chats: the authoritative timer is stored on each contact record in this
|
||||
database, alongside the mapping between a contact's LID and phone number
|
||||
identifiers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
contact = _describe_contact(record)
|
||||
|
||||
if record.get("disappearing_mode_timestamp"):
|
||||
records.append(
|
||||
{
|
||||
"timestamp": record["disappearing_mode_timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "disappearing_mode_set",
|
||||
"data": (
|
||||
f"WhatsApp disappearing messages timer set to "
|
||||
f"'{record.get('disappearing_mode_label')}' "
|
||||
f"for {contact}"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if record.get("about_timestamp"):
|
||||
data = f"WhatsApp about text of {contact} changed"
|
||||
about_text = record.get("about_text")
|
||||
if about_text:
|
||||
data += f' to "{about_text}"'
|
||||
records.append(
|
||||
{
|
||||
"timestamp": record["about_timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "about_changed",
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
if record.get("about_expiration_timestamp"):
|
||||
records.append(
|
||||
{
|
||||
"timestamp": record["about_expiration_timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "about_expiration",
|
||||
"data": (
|
||||
f"WhatsApp about text of {contact} scheduled "
|
||||
f"to expire"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if record.get("last_updated"):
|
||||
records.append(
|
||||
{
|
||||
"timestamp": record["last_updated"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "contact_last_updated",
|
||||
"data": f"WhatsApp contact record for {contact} updated",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self._find_ios_database(
|
||||
backup_ids=WHATSAPP_CONTACTS_BACKUP_IDS,
|
||||
root_paths=WHATSAPP_CONTACTS_ROOT_PATHS,
|
||||
)
|
||||
except DatabaseNotFoundError:
|
||||
self.log.warning(
|
||||
"Unable to find the WhatsApp ContactsV2.sqlite database in "
|
||||
"this backup or filesystem dump. WhatsApp disappearing "
|
||||
"messages settings and contact records cannot be extracted. "
|
||||
"This database is often missing from incremental backups."
|
||||
)
|
||||
return
|
||||
|
||||
self.log.info(
|
||||
"Found WhatsApp contacts database at path: %s", self.file_path
|
||||
)
|
||||
|
||||
assert self.file_path is not None
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
try:
|
||||
cur.execute("PRAGMA table_info(ZWAADDRESSBOOKCONTACT)")
|
||||
available_columns = {row[1] for row in cur.fetchall()}
|
||||
except sqlite3.DatabaseError as exc:
|
||||
self.log.error(
|
||||
"Unable to read the ZWAADDRESSBOOKCONTACT table schema: %s",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
if not available_columns:
|
||||
self.log.warning(
|
||||
"The WhatsApp contacts database does not contain a "
|
||||
"ZWAADDRESSBOOKCONTACT table"
|
||||
)
|
||||
return
|
||||
|
||||
selected = {}
|
||||
for field, candidates in COLUMN_CANDIDATES.items():
|
||||
for candidate in candidates:
|
||||
if candidate in available_columns:
|
||||
selected[field] = candidate
|
||||
break
|
||||
|
||||
# A record with no duration column is "unknown", not "off": the
|
||||
# timer state cannot be determined from this database version.
|
||||
has_duration = "disappearing_mode_duration" in selected
|
||||
if not has_duration:
|
||||
self.log.warning(
|
||||
"The ZDISAPPEARINGMODEDURATION column is not present in "
|
||||
"this WhatsApp contacts database: disappearing messages "
|
||||
"state is unknown"
|
||||
)
|
||||
|
||||
columns = ["Z_PK"] + list(selected.values())
|
||||
cur.execute(
|
||||
f"SELECT {', '.join(columns)} FROM ZWAADDRESSBOOKCONTACT;"
|
||||
)
|
||||
fields = ["row_pk"] + list(selected.keys())
|
||||
|
||||
for row in cur:
|
||||
record = dict(zip(fields, row))
|
||||
|
||||
for field in STRING_FIELDS:
|
||||
if field in record:
|
||||
record[field] = _decode_string(record[field])
|
||||
else:
|
||||
record[field] = None
|
||||
|
||||
for field in DATE_FIELDS:
|
||||
if record.get(field) is not None:
|
||||
record[field] = (
|
||||
convert_mactime_to_iso(record[field]) or None
|
||||
)
|
||||
else:
|
||||
record[field] = None
|
||||
|
||||
duration = record.get("disappearing_mode_duration")
|
||||
if has_duration:
|
||||
record["disappearing_mode_is_on"] = bool(duration)
|
||||
record["disappearing_mode_label"] = _label_duration(
|
||||
duration
|
||||
)
|
||||
else:
|
||||
record["disappearing_mode_duration"] = None
|
||||
record["disappearing_mode_is_on"] = None
|
||||
record["disappearing_mode_label"] = None
|
||||
|
||||
record.setdefault("phone_status", None)
|
||||
record.setdefault("sync_policy", None)
|
||||
|
||||
self.results.append(record)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
total_ephemeral = sum(
|
||||
1 for record in self.results if record["disappearing_mode_is_on"]
|
||||
)
|
||||
self.log.info(
|
||||
"Extracted a total of %d WhatsApp contacts (%d with disappearing "
|
||||
"messages enabled)",
|
||||
len(self.results),
|
||||
total_ephemeral,
|
||||
)
|
||||
@@ -42,7 +42,8 @@ class NetBase(IOSExtraction):
|
||||
)
|
||||
|
||||
def _extract_net_data(self):
|
||||
conn = sqlite3.connect(self.file_path)
|
||||
assert self.file_path is not None
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
@@ -237,9 +238,7 @@ class NetBase(IOSExtraction):
|
||||
"been truncated in the database)"
|
||||
)
|
||||
|
||||
self.alertstore.medium(
|
||||
msg, proc["live_isodate"], proc
|
||||
)
|
||||
self.alertstore.medium(msg, proc["live_isodate"], proc)
|
||||
|
||||
if not proc["live_proc_id"]:
|
||||
self.log.info(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .base import SysdiagnoseExtraction
|
||||
@@ -0,0 +1,105 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 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 fnmatch
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module import MVTModule, ModuleResults
|
||||
|
||||
|
||||
class SysdiagnoseExtraction(MVTModule):
|
||||
"""Base class for custom modules that analyze an iOS sysdiagnose."""
|
||||
|
||||
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.parent_path: Optional[str] = None
|
||||
self.files: list[str] = []
|
||||
self.tar: Optional[tarfile.TarFile] = None
|
||||
self.tar_files: list[str] = []
|
||||
self.ips_files: list[dict[str, object]] = []
|
||||
|
||||
def from_sysdiagnose_folder(
|
||||
self, target_path: str, sysdiagnose_files: list[str]
|
||||
) -> None:
|
||||
self.parent_path = Path(target_path).absolute().parent.as_posix()
|
||||
self.files = sysdiagnose_files
|
||||
|
||||
def from_sysdiagnose_tar(
|
||||
self, sysdiagnose_archive: tarfile.TarFile, sysdiagnose_files: list[str]
|
||||
) -> None:
|
||||
self.tar = sysdiagnose_archive
|
||||
self.tar_files = sysdiagnose_files
|
||||
|
||||
def _extract_timezone(self):
|
||||
"""Determine the sysdiagnose timezone from its diagnostic log."""
|
||||
file_paths = self._get_files_by_pattern("*/sysdiagnose.log")
|
||||
if not file_paths:
|
||||
self.log.info(
|
||||
"Unable to determine the timezone in which the sysdiagnose was "
|
||||
"generated. Assuming UTC for logs without a timezone."
|
||||
)
|
||||
return timezone.utc
|
||||
|
||||
content = self._get_file_content(file_paths[0]).decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
filenames = re.findall(r"sysdiagnose_\S+?\.tar\.gz", content)
|
||||
if not filenames:
|
||||
self.log.info(
|
||||
"Unable to determine the timezone in which the sysdiagnose was "
|
||||
"generated. Assuming UTC for logs without a timezone."
|
||||
)
|
||||
return timezone.utc
|
||||
|
||||
timestamp = "_".join(
|
||||
filenames[0].removesuffix(".tar.gz").split("_")[1:3]
|
||||
)
|
||||
sysdiagnose_timezone = datetime.strptime(
|
||||
timestamp, "%Y.%m.%d_%H-%M-%S%z"
|
||||
).tzinfo
|
||||
self.log.info(
|
||||
"Based on the sysdiagnose filename, assuming timezone %s for logs "
|
||||
"without a timezone.",
|
||||
sysdiagnose_timezone,
|
||||
)
|
||||
return sysdiagnose_timezone
|
||||
|
||||
def _get_files_by_pattern(self, pattern: str) -> list[str]:
|
||||
file_names = self.tar_files if self.tar else self.files
|
||||
return fnmatch.filter(file_names, pattern)
|
||||
|
||||
def _get_file_content(self, file_path: str) -> bytes:
|
||||
if self.tar:
|
||||
handle = self.tar.extractfile(self.tar.getmember(file_path))
|
||||
else:
|
||||
if self.parent_path is None:
|
||||
raise RuntimeError("Sysdiagnose folder has not been initialized")
|
||||
handle = open(os.path.join(self.parent_path, file_path), "rb")
|
||||
|
||||
if handle is None:
|
||||
return b""
|
||||
with handle:
|
||||
return handle.read()
|
||||
@@ -4,6 +4,8 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
|
||||
from mvt.android.modules.bugreport.dumpsys_adb_state import DumpsysADBState
|
||||
from mvt.common.alerts import AlertLevel
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
@@ -30,6 +32,66 @@ class TestDumpsysADBArtifact:
|
||||
)
|
||||
assert user_key["user"] == "user@linux"
|
||||
|
||||
def test_parsing_adb_wifi(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
file = get_artifact("android_data/dumpsys_adb_wifi.txt")
|
||||
with open(file, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
da_adb.parse(data)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
adb_data = da_adb.results[0]
|
||||
assert "user_keys" in adb_data
|
||||
assert len(adb_data["user_keys"]) == 1
|
||||
|
||||
user_key = adb_data["user_keys"][0]
|
||||
assert (
|
||||
user_key["fingerprint"] == "F0:A1:3D:8C:B3:F4:7B:09:9F:EE:8B:D8:38:2E:BD:C6"
|
||||
)
|
||||
assert user_key["user"] == "user@linux"
|
||||
|
||||
# The adb_wifi block following the keystore is not part of the keystore.
|
||||
assert b"adb_wifi" not in adb_data["keystore"]
|
||||
|
||||
def test_parsing_multiline_terminated_by_structural_line(self):
|
||||
dump_data = (
|
||||
b"debugging_manager={\n"
|
||||
b" keystore=ABX\x00\x0bkeyStore\x00\x02\x11\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" adb_wifi={\n"
|
||||
b" enabled=false\n"
|
||||
b" tls_port=0\n"
|
||||
b" }\n"
|
||||
)
|
||||
|
||||
parsed = DumpsysADBArtifact().indented_dump_parser(dump_data)
|
||||
|
||||
debugging_manager = parsed["debugging_manager"]
|
||||
assert debugging_manager["keystore"] == [b"ABX\x00\x0bkeyStore\x00\x02\x11"]
|
||||
assert debugging_manager["connected_to_adb"] == b"true"
|
||||
assert debugging_manager["adb_wifi"] == {
|
||||
"enabled": b"false",
|
||||
"tls_port": b"0",
|
||||
}
|
||||
|
||||
def test_parsing_multiline_terminated_by_closing_brace(self):
|
||||
dump_data = (
|
||||
b"debugging_manager={\n"
|
||||
b" keystore=ABX\x00\x0bkeyStore\x00\x02\x11\n"
|
||||
b"}\n"
|
||||
b"other={\n"
|
||||
b" value=true\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
parsed = DumpsysADBArtifact().indented_dump_parser(dump_data)
|
||||
|
||||
assert parsed["debugging_manager"]["keystore"] == [
|
||||
b"ABX\x00\x0bkeyStore\x00\x02\x11"
|
||||
]
|
||||
assert parsed["other"] == {"value": b"true"}
|
||||
|
||||
def test_parsing_adb_xml(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
file = get_artifact("android_data/dumpsys_adb_xml.txt")
|
||||
@@ -54,3 +116,119 @@ class TestDumpsysADBArtifact:
|
||||
assert key_store_entry["user"] == "user@laptop"
|
||||
assert key_store_entry["fingerprint"] == expected_fingerprint
|
||||
assert key_store_entry["last_connected"] == "1628501829898"
|
||||
|
||||
|
||||
class TestDumpsysADBStateAlerts:
|
||||
def test_no_androidqf_context_preserves_existing_behavior(self):
|
||||
module = DumpsysADBState(
|
||||
results=[
|
||||
{
|
||||
"user_keys": [
|
||||
{
|
||||
"key": b"QUJDRA==",
|
||||
"user": "host@example",
|
||||
"fingerprint": "fingerprint",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
module.check_indicators()
|
||||
|
||||
assert module.alertstore.alerts == []
|
||||
|
||||
def test_androidqf_trusted_keys_create_expected_alerts(self):
|
||||
module = DumpsysADBState(
|
||||
module_options={
|
||||
"androidqf_acquisition": {
|
||||
"started": "2025-06-20T18:00:00Z",
|
||||
"adb_host_public_key": "QUJDRA== acquisition@host",
|
||||
}
|
||||
},
|
||||
results=[
|
||||
{
|
||||
"user_keys": [
|
||||
{
|
||||
"key": b"QUJDRA==",
|
||||
"user": "acquisition@host",
|
||||
"fingerprint": "acquisition-fingerprint",
|
||||
},
|
||||
{
|
||||
"key": b"RUZHSA==",
|
||||
"user": "other@host",
|
||||
"fingerprint": "other-fingerprint",
|
||||
},
|
||||
{
|
||||
"key": b"not-base64",
|
||||
"user": "invalid@host",
|
||||
"fingerprint": "",
|
||||
},
|
||||
],
|
||||
"keystore": [
|
||||
{
|
||||
"key": b"QUJDRA==",
|
||||
"user": "acquisition@host",
|
||||
"fingerprint": "acquisition-fingerprint",
|
||||
"last_connected": "1750266000000",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
module.check_indicators()
|
||||
|
||||
assert [alert.level for alert in module.alertstore.alerts] == [
|
||||
AlertLevel.INFORMATIONAL,
|
||||
AlertLevel.LOW,
|
||||
AlertLevel.LOW,
|
||||
]
|
||||
informational, different, invalid = module.alertstore.alerts
|
||||
assert "at least one day before" in informational.message
|
||||
assert informational.event_time == "2025-06-18 17:00:00.000000"
|
||||
assert "different from the AndroidQF acquisition host" in different.message
|
||||
assert "invalid trusted ADB host key" in invalid.message
|
||||
|
||||
def test_missing_androidqf_host_key_creates_low_alert(self):
|
||||
trusted_key = {
|
||||
"key": b"QUJDRA==",
|
||||
"user": "host@example",
|
||||
"fingerprint": "fingerprint",
|
||||
}
|
||||
module = DumpsysADBState(
|
||||
module_options={"androidqf_acquisition": {}},
|
||||
results=[{"user_keys": [trusted_key]}],
|
||||
)
|
||||
|
||||
module.check_indicators()
|
||||
|
||||
assert len(module.alertstore.alerts) == 1
|
||||
assert module.alertstore.alerts[0].level == AlertLevel.LOW
|
||||
assert "does not include its host key" in module.alertstore.alerts[0].message
|
||||
|
||||
def test_recent_acquisition_host_key_does_not_create_alert(self):
|
||||
module = DumpsysADBState(
|
||||
module_options={
|
||||
"androidqf_acquisition": {
|
||||
"started": "2025-06-20T18:00:00Z",
|
||||
"adb_host_public_key": "QUJDRA== acquisition@host",
|
||||
}
|
||||
},
|
||||
results=[
|
||||
{
|
||||
"keystore": [
|
||||
{
|
||||
"key": b"QUJDRA==",
|
||||
"user": "acquisition@host",
|
||||
"fingerprint": "fingerprint",
|
||||
"last_connected": "1750438800000",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
module.check_indicators()
|
||||
|
||||
assert module.alertstore.alerts == []
|
||||
@@ -69,3 +69,65 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
assert downgrade_alert.event["package_name"] == "com.example.app"
|
||||
assert downgrade_alert.event["action"] == "downgrade"
|
||||
assert downgrade_alert.event["previous_vers"] == "10"
|
||||
|
||||
def test_newest_first_update_is_not_reported_as_downgrade(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
"""
|
||||
Daily from 2026-01-10 to 2026-01-11:
|
||||
Update com.example.app vers=102
|
||||
Daily from 2026-01-05 to 2026-01-06:
|
||||
Update com.example.app vers=101
|
||||
"""
|
||||
)
|
||||
|
||||
assert len(dba.results) == 2
|
||||
assert len(dba.alertstore.alerts) == 0
|
||||
assert all(result["action"] == "update" for result in dba.results)
|
||||
|
||||
def test_newest_first_downgrade_creates_medium_alert(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
"""
|
||||
Daily from 2026-01-10 to 2026-01-11:
|
||||
Update com.example.app vers=101
|
||||
Daily from 2026-01-05 to 2026-01-06:
|
||||
Update com.example.app vers=102
|
||||
"""
|
||||
)
|
||||
|
||||
assert len(dba.results) == 2
|
||||
assert len(dba.alertstore.alerts) == 1
|
||||
|
||||
downgrade_alert = dba.alertstore.alerts[0]
|
||||
assert downgrade_alert.level == AlertLevel.MEDIUM
|
||||
assert downgrade_alert.message == (
|
||||
"Detected downgrade of package com.example.app from vers 102 to vers 101"
|
||||
)
|
||||
assert downgrade_alert.event_time == "2026-01-10"
|
||||
assert downgrade_alert.event["package_name"] == "com.example.app"
|
||||
assert downgrade_alert.event["action"] == "downgrade"
|
||||
assert downgrade_alert.event["previous_vers"] == "102"
|
||||
|
||||
def test_reinstall_after_uninstall_is_not_reported_as_downgrade(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
"""
|
||||
Daily from 2026-01-15 to 2026-01-16:
|
||||
Update com.example.app vers=10
|
||||
Daily from 2026-01-10 to 2026-01-11:
|
||||
Update com.example.app vers=0
|
||||
Daily from 2026-01-05 to 2026-01-06:
|
||||
Update com.example.app vers=102
|
||||
"""
|
||||
)
|
||||
|
||||
assert len(dba.results) == 3
|
||||
assert len(dba.alertstore.alerts) == 1
|
||||
|
||||
uninstall_alert = dba.alertstore.alerts[0]
|
||||
assert uninstall_alert.level == AlertLevel.MEDIUM
|
||||
assert uninstall_alert.message == (
|
||||
"Detected uninstall of package com.example.app (vers 0)"
|
||||
)
|
||||
assert uninstall_alert.event_time == "2026-01-10"
|
||||
@@ -42,3 +42,22 @@ class TestDumpsysBatteryHistoryArtifact:
|
||||
assert len(dba.alertstore.alerts) == 0
|
||||
dba.check_indicators()
|
||||
assert len(dba.alertstore.alerts) == 2
|
||||
|
||||
def test_parsing_absolute_timestamps(self):
|
||||
dba = DumpsysBatteryHistoryArtifact()
|
||||
dba.parse(
|
||||
"""07-15 20:27:39.431 (2) 100 +job=u0a123:"com.example/.ExampleJob"
|
||||
07-15 20:27:40.431 (2) 100 -job=u0a123:"com.example/.ExampleJob"
|
||||
"""
|
||||
)
|
||||
|
||||
assert len(dba.results) == 2
|
||||
assert dba.results[0] == {
|
||||
"time_elapsed": "07-15 20:27:39.431",
|
||||
"event": "start_job",
|
||||
"uid": "u0a123",
|
||||
"package_name": "com.example",
|
||||
"service": "com.example/.ExampleJob",
|
||||
}
|
||||
assert dba.results[1]["event"] == "end_job"
|
||||
assert dba.results[1]["uid"] == "u0a123"
|
||||
@@ -40,3 +40,22 @@ class TestDumpsysDBinfoArtifact:
|
||||
assert len(dbi.alertstore.alerts) == 0
|
||||
dbi.check_indicators()
|
||||
assert len(dbi.alertstore.alerts) == 5
|
||||
|
||||
def test_parsing_month_day_timestamp_without_pid(self):
|
||||
dbi = DumpsysDBInfoArtifact()
|
||||
dbi.parse(
|
||||
"""
|
||||
Connection pool for /data/user/0/com.example/databases/current.db:
|
||||
Most recently executed operations:
|
||||
0: [07-15 20:27:39.431] executeForCursorWindow took 1ms - succeeded, sql="SELECT 1"
|
||||
"""
|
||||
)
|
||||
|
||||
assert dbi.results == [
|
||||
{
|
||||
"isodate": "07-15 20:27:39.431",
|
||||
"action": "executeForCursorWindow",
|
||||
"sql": "SELECT 1",
|
||||
"path": "/data/user/0/com.example/databases/current.db",
|
||||
}
|
||||
]
|
||||
@@ -25,6 +25,25 @@ class TestDumpsysPackagesArtifact:
|
||||
== "com.samsung.android.provider.filterprovider"
|
||||
)
|
||||
assert dpa.results[0]["version_name"] == "5.0.07"
|
||||
assert dpa.results[0]["first_install_time"] == "2008-12-31 16:00:00"
|
||||
assert dpa.results[0]["system"] is True
|
||||
|
||||
def test_parsing_system_flag(self):
|
||||
system_details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
|
||||
" pkgFlags=[ SYSTEM HAS_CODE ALLOW_CLEAR_USER_DATA ]"
|
||||
)
|
||||
third_party_details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
|
||||
" pkgFlags=[ HAS_CODE ALLOW_BACKUP ]"
|
||||
)
|
||||
missing_flag_details = (
|
||||
DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
|
||||
" versionName=1.0"
|
||||
)
|
||||
)
|
||||
|
||||
assert system_details["system"] is True
|
||||
assert third_party_details["system"] is False
|
||||
assert missing_flag_details["system"] is False
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
dpa = DumpsysPackagesArtifact()
|
||||
@@ -40,3 +59,43 @@ class TestDumpsysPackagesArtifact:
|
||||
assert len(dpa.alertstore.alerts) == 0
|
||||
dpa.check_indicators()
|
||||
assert len(dpa.alertstore.alerts) == 1
|
||||
|
||||
def test_per_user_fields_use_primary_user(self):
|
||||
details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
|
||||
""" User 0: installed=true
|
||||
firstInstallTime=2024-01-10 09:19:39
|
||||
runtime permissions:
|
||||
android.permission.CAMERA: granted=true
|
||||
User 95: installed=false
|
||||
firstInstallTime=1970-01-01 01:00:00
|
||||
runtime permissions:
|
||||
android.permission.CAMERA: granted=false
|
||||
android.permission.RECORD_AUDIO: granted=false
|
||||
"""
|
||||
)
|
||||
|
||||
assert details["first_install_time"] == "2024-01-10 09:19:39"
|
||||
runtime_permissions = [
|
||||
permission
|
||||
for permission in details["permissions"]
|
||||
if permission["type"] == "runtime"
|
||||
]
|
||||
assert runtime_permissions == [
|
||||
{
|
||||
"name": "android.permission.CAMERA",
|
||||
"granted": True,
|
||||
"type": "runtime",
|
||||
}
|
||||
]
|
||||
|
||||
def test_per_user_fields_fall_back_when_user_zero_is_missing(self):
|
||||
details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
|
||||
""" User 10: installed=true
|
||||
firstInstallTime=2024-02-10 09:19:39
|
||||
runtime permissions:
|
||||
android.permission.CAMERA: granted=true
|
||||
"""
|
||||
)
|
||||
|
||||
assert details["first_install_time"] == "2024-02-10 09:19:39"
|
||||
assert details["permissions"][-1]["name"] == "android.permission.CAMERA"
|
||||
@@ -31,6 +31,44 @@ class TestDumpsysReceiversArtifact:
|
||||
== "com.android.storagemanager"
|
||||
)
|
||||
|
||||
def test_parsing_misindented_action(self):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
data = """\
|
||||
Receiver Resolver Table:
|
||||
Non-Data Actions:
|
||||
android.app.action.ENTER_CAR_MODE:
|
||||
b5b40f6 com.google.android.projection.gearhead/.CarModeBroadcastReceiver
|
||||
android.intent.action.MY_PACKAGE_REPLACED:
|
||||
e7706c1 com.psycatgames.nhiegame/.ScheduledNotificationBootReceiver
|
||||
"""
|
||||
|
||||
dr.parse(data)
|
||||
|
||||
assert (
|
||||
dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][
|
||||
"package_name"
|
||||
]
|
||||
== "com.psycatgames.nhiegame"
|
||||
)
|
||||
|
||||
def test_parsing_misindented_first_action(self):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
data = """\
|
||||
Receiver Resolver Table:
|
||||
Non-Data Actions:
|
||||
android.intent.action.MY_PACKAGE_REPLACED:
|
||||
e7706c1 com.psycatgames.nhiegame/.ScheduledNotificationBootReceiver
|
||||
"""
|
||||
|
||||
dr.parse(data)
|
||||
|
||||
assert (
|
||||
dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][
|
||||
"package_name"
|
||||
]
|
||||
== "com.psycatgames.nhiegame"
|
||||
)
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
file = get_artifact("android_data/dumpsys_packages.txt")
|
||||
|
||||
@@ -8,6 +8,7 @@ import datetime
|
||||
import pytest
|
||||
|
||||
from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact
|
||||
from mvt.android.parsers.proto.tombstone import Tombstone
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
@@ -42,6 +43,73 @@ class TestTombstoneCrashArtifact:
|
||||
assert len(tombstone_artifact.results) == 1
|
||||
self.validate_tombstone_result(tombstone_artifact.results[0])
|
||||
|
||||
def test_text_tombstone_preserves_abort_message(self):
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
artifact_path = "android_data/bugreport/FS/data/tombstones/tombstone_00"
|
||||
file = get_artifact(artifact_path)
|
||||
with open(file, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
tombstone_artifact.parse(
|
||||
os.path.basename(artifact_path),
|
||||
datetime.datetime(2021, 9, 29, 17, 43, 49),
|
||||
data,
|
||||
)
|
||||
|
||||
assert tombstone_artifact.results[0]["abort_message"] == (
|
||||
"Check failed: payload.size() <= bytes_left "
|
||||
"(payload.size()=99, bytes_left=51) "
|
||||
)
|
||||
|
||||
def test_protobuf_tombstone_preserves_abort_message_and_causes(self):
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
artifact_path = "android_data/tombstone_process.pb"
|
||||
file = get_artifact(artifact_path)
|
||||
with open(file, "rb") as f:
|
||||
tombstone = Tombstone().parse(f.read())
|
||||
|
||||
tombstone.abort_message = "synthetic abort reason"
|
||||
tombstone_artifact.parse_protobuf(
|
||||
os.path.basename(artifact_path),
|
||||
datetime.datetime(2023, 4, 12, 12, 32, 40, 518290),
|
||||
bytes(tombstone),
|
||||
)
|
||||
|
||||
result = tombstone_artifact.results[0]
|
||||
assert result["abort_message"] == "synthetic abort reason"
|
||||
assert result["causes"] == [
|
||||
{
|
||||
"human_readable": "null pointer dereference",
|
||||
"memory_error": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_text_tombstone_keeps_crashing_thread(self):
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
artifact_path = "android_data/tombstone_process.txt"
|
||||
file = get_artifact(artifact_path)
|
||||
with open(file, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
data += (
|
||||
b"\npid: 25541, tid: 31896, name: worker-thread"
|
||||
b" >>> /vendor/bin/other <<<\n"
|
||||
)
|
||||
tombstone_artifact.parse(
|
||||
os.path.basename(artifact_path),
|
||||
datetime.datetime(2023, 4, 12, 12, 32, 40, 518290),
|
||||
data,
|
||||
)
|
||||
|
||||
result = tombstone_artifact.results[0]
|
||||
assert result["pid"] == 25541
|
||||
assert result["tid"] == 21307
|
||||
assert result["process_name"] == "mtk.ape.decoder"
|
||||
assert (
|
||||
result["binary_path"]
|
||||
== "/vendor/bin/hw/android.hardware.media.c2@1.2-mediatek"
|
||||
)
|
||||
|
||||
@pytest.mark.skip(reason="Not implemented yet")
|
||||
def test_tombtone_kernel_parsing(self):
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
|
||||
@@ -5,12 +5,35 @@
|
||||
|
||||
import hashlib
|
||||
|
||||
from mvt.android.parsers.backup import parse_backup_file, parse_tar_for_sms
|
||||
import pytest
|
||||
|
||||
from mvt.android.parsers.backup import (
|
||||
AndroidBackupParsingError,
|
||||
parse_ab_header,
|
||||
parse_backup_file,
|
||||
parse_tar_for_sms,
|
||||
)
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
|
||||
class TestBackupParsing:
|
||||
def test_parse_incomplete_header(self):
|
||||
assert parse_ab_header(b"ANDROID BACKUP\n") == {
|
||||
"backup": False,
|
||||
"compression": None,
|
||||
"version": None,
|
||||
"encryption": None,
|
||||
}
|
||||
|
||||
def test_parse_truncated_encrypted_header(self):
|
||||
with pytest.raises(
|
||||
AndroidBackupParsingError, match="Invalid encrypted backup header"
|
||||
):
|
||||
parse_backup_file(
|
||||
b"ANDROID BACKUP\n5\n0\nAES-256\ntruncated", password="password"
|
||||
)
|
||||
|
||||
def test_parsing_noencryption(self):
|
||||
file = get_artifact("android_backup/backup.ab")
|
||||
with open(file, "rb") as f:
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.android.cli import check_intrusion_logs
|
||||
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
||||
from mvt.android.modules.intrusion_logs.base import IntrusionLogsModule
|
||||
from mvt.android.modules.intrusion_logs.security_event import SecurityEvent
|
||||
from mvt.common.alerts import AlertLevel
|
||||
|
||||
|
||||
def _write_ndjson(path, records):
|
||||
@@ -143,6 +145,58 @@ def test_check_intrusion_logs_parses_core_and_unknown_security_events(
|
||||
assert "Please open an issue on GitHub" in caplog.text
|
||||
|
||||
|
||||
def test_check_intrusion_logs_treats_event_id_as_security_event_metadata(
|
||||
tmp_path, caplog
|
||||
):
|
||||
_write_ndjson(
|
||||
tmp_path / "intrusion.txt",
|
||||
[
|
||||
{
|
||||
"security_event": {
|
||||
"event_id": 191,
|
||||
"event_time": 1_700_000_002_000_000_000,
|
||||
"keyguard_dismiss_auth_attempt": {
|
||||
"success": True,
|
||||
"method_strength": 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"security_event": {
|
||||
"event_id": 192,
|
||||
"event_time": 1_700_000_003_000_000_000,
|
||||
"keyguard_dismissed": {},
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd = CmdAndroidCheckIntrusionLogs(target_path=str(tmp_path))
|
||||
cmd.run()
|
||||
|
||||
security_module = next(
|
||||
module for module in cmd.executed if isinstance(module, SecurityEvent)
|
||||
)
|
||||
assert security_module.event_type_counts == {
|
||||
"keyguard_dismiss_auth_attempt": 1,
|
||||
"keyguard_dismissed": 1,
|
||||
}
|
||||
assert [event["event_id"] for event in security_module.results] == [191, 192]
|
||||
|
||||
keyguard_events = {
|
||||
event["event"]: event
|
||||
for event in cmd.timeline
|
||||
if event["event"]
|
||||
in {"keyguard_dismiss_auth_attempt", "keyguard_dismissed"}
|
||||
}
|
||||
assert "Auth attempt: Success" in keyguard_events[
|
||||
"keyguard_dismiss_auth_attempt"
|
||||
]["data"]
|
||||
assert keyguard_events["keyguard_dismissed"]["data"] == "Keyguard dismissed"
|
||||
assert "unknown intrusion logging security event type(s): event_id" not in caplog.text
|
||||
|
||||
|
||||
def test_check_intrusion_logs_cli_lists_modules(tmp_path):
|
||||
_write_ndjson(tmp_path / "intrusion.txt", [])
|
||||
|
||||
@@ -152,3 +206,128 @@ def test_check_intrusion_logs_cli_lists_modules(tmp_path):
|
||||
assert "DnsEvent" in result.output
|
||||
assert "ConnectEvent" in result.output
|
||||
assert "SecurityEvent" in result.output
|
||||
|
||||
|
||||
def _run_security_heuristics(results):
|
||||
# No indicators loaded: heuristic alerts must still fire.
|
||||
module = SecurityEvent(results=results)
|
||||
module.check_indicators()
|
||||
return module.alertstore.alerts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("success", [False, 0])
|
||||
def test_known_pinstorage_key_generation_failure_does_not_warn(success, caplog):
|
||||
record = {
|
||||
"timestamp": "2026-06-17 15:31:02.014",
|
||||
"key_generated": {
|
||||
"success": success,
|
||||
"key_id": "PinStorage_crossReboot_key",
|
||||
"uid": 1001,
|
||||
},
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_run_security_heuristics([record])
|
||||
|
||||
assert "Failed key generation detected" not in caplog.text
|
||||
|
||||
timeline_event = SecurityEvent().serialize(record)
|
||||
assert timeline_event["event"] == "key_generated"
|
||||
assert "Key generation failed: PinStorage_crossReboot_key" in timeline_event["data"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key_id", "uid"),
|
||||
[
|
||||
("PinStorage_crossReboot_key", 10_000),
|
||||
("another_key", 1001),
|
||||
],
|
||||
)
|
||||
def test_other_key_generation_failures_still_warn(key_id, uid, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_run_security_heuristics(
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-06-17 15:31:02.014",
|
||||
"key_generated": {
|
||||
"success": False,
|
||||
"key_id": key_id,
|
||||
"uid": uid,
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert f"Failed key generation detected for key_id: {key_id}" in caplog.text
|
||||
|
||||
|
||||
def test_cert_authority_installed_raises_medium_alert_without_indicators():
|
||||
alerts = _run_security_heuristics(
|
||||
[
|
||||
{
|
||||
"timestamp": "2024-01-01 00:00:00.000",
|
||||
"cert_authority_installed": {
|
||||
"subject": "CN=Unexpected Root CA",
|
||||
"success": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0].level == AlertLevel.MEDIUM
|
||||
assert "Certificate authority installed" in alerts[0].message
|
||||
assert "Unexpected Root CA" in alerts[0].message
|
||||
|
||||
|
||||
# Exported logs encode success as a JSON bool, raw SecurityLog as int 0/1.
|
||||
@pytest.mark.parametrize("success", [False, 0])
|
||||
def test_failed_cert_authority_install_does_not_alert(success, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
alerts = _run_security_heuristics(
|
||||
[
|
||||
{
|
||||
"timestamp": "2024-01-01 00:00:00.000",
|
||||
"cert_authority_installed": {
|
||||
"subject": "CN=Unexpected Root CA",
|
||||
"success": success,
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert alerts == []
|
||||
assert "Failed certificate authority install attempt" in caplog.text
|
||||
assert "Unexpected Root CA" in caplog.text
|
||||
|
||||
|
||||
def test_cert_validation_failure_raises_medium_alert_without_indicators():
|
||||
alerts = _run_security_heuristics(
|
||||
[
|
||||
{
|
||||
"timestamp": "2024-01-01 00:00:00.000",
|
||||
"cert_validation_failure": "chain validation failed",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0].level == AlertLevel.MEDIUM
|
||||
assert "Certificate validation failure" in alerts[0].message
|
||||
|
||||
|
||||
def test_security_heuristics_fire_when_no_indicators_loaded():
|
||||
# check_indicators() previously returned early with no indicators loaded,
|
||||
# so none of the heuristic alerts fired on a default run.
|
||||
alerts = _run_security_heuristics(
|
||||
[
|
||||
{"timestamp": "2024-01-01 00:00:00.000", "wipe_failure": {"reason": "x"}},
|
||||
{
|
||||
"timestamp": "2024-01-01 00:00:00.000",
|
||||
"key_integrity_violation": {"key_id": "k1"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert len(alerts) == 2
|
||||
assert all(alert.level == AlertLevel.MEDIUM for alert in alerts)
|
||||
@@ -7,8 +7,11 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.android.cli import check_androidqf
|
||||
from mvt.android.modules.androidqf.aqf_packages import AQFPackages
|
||||
from mvt.android.modules.androidqf import aqf_packages as aqf_packages_module
|
||||
from mvt.common.module import run_module
|
||||
|
||||
from ..utils import get_android_androidqf, list_files
|
||||
@@ -132,3 +135,55 @@ class TestAndroidqfPackages:
|
||||
possible_detected_app[0].matched_indicator.value
|
||||
== "c7e56178748be1441370416d4c10e34817ea0c961eb636c8e9d98e0fd79bf730"
|
||||
)
|
||||
|
||||
def test_virustotal_delays_after_missing_result(self, monkeypatch):
|
||||
lookups = []
|
||||
sleeps = []
|
||||
|
||||
def fake_virustotal_lookup(file_hash):
|
||||
lookups.append(file_hash)
|
||||
if file_hash == "missing_hash":
|
||||
return None
|
||||
return {
|
||||
"attributes": {
|
||||
"last_analysis_stats": {"malicious": 1},
|
||||
"last_analysis_results": {"engine": {}},
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aqf_packages_module, "virustotal_lookup", fake_virustotal_lookup
|
||||
)
|
||||
monkeypatch.setattr(aqf_packages_module.time, "sleep", sleeps.append)
|
||||
|
||||
module = AQFPackages(
|
||||
module_options={"virustotal": True, "virustotal_delay": 16},
|
||||
results=[
|
||||
{
|
||||
"name": "org.example",
|
||||
"installer": "com.android.vending",
|
||||
"disabled": False,
|
||||
"system": False,
|
||||
"files": [
|
||||
{"path": "/data/app/missing.apk", "sha256": "missing_hash"},
|
||||
{"path": "/data/app/found.apk", "sha256": "found_hash"},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
module.check_indicators()
|
||||
|
||||
assert lookups == ["missing_hash", "found_hash"]
|
||||
assert sleeps == [16]
|
||||
assert module.results[0]["files"][1]["virustotal"] == "1/1"
|
||||
assert len(module.alertstore.alerts) == 1
|
||||
|
||||
|
||||
def test_check_androidqf_rejects_negative_virustotal_delay(data_path):
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(check_androidqf, ["--delay", "-1", data_path])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "Invalid value for '--delay'" in result.output
|
||||
@@ -1,91 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from mvt.android.modules.androidqf.sms import SMS
|
||||
from mvt.common.module import run_module
|
||||
|
||||
from ..utils import get_android_androidqf, get_artifact_folder, list_files
|
||||
|
||||
TEST_BACKUP_PASSWORD = "123456"
|
||||
|
||||
|
||||
class TestAndroidqfSMSAnalysis:
|
||||
def test_androidqf_sms(self):
|
||||
data_path = get_android_androidqf()
|
||||
m = SMS(target_path=data_path, log=logging)
|
||||
files = list_files(data_path)
|
||||
parent_path = Path(data_path).absolute().parent.as_posix()
|
||||
m.from_dir(parent_path, files)
|
||||
run_module(m)
|
||||
assert len(m.results) == 2
|
||||
assert len(m.timeline) == 0
|
||||
assert len(m.alertstore.alerts) == 0
|
||||
|
||||
def test_androidqf_sms_encrypted_password_valid(self):
|
||||
data_path = os.path.join(get_artifact_folder(), "androidqf_encrypted")
|
||||
m = SMS(
|
||||
target_path=data_path,
|
||||
log=logging,
|
||||
module_options={"backup_password": TEST_BACKUP_PASSWORD},
|
||||
)
|
||||
files = list_files(data_path)
|
||||
parent_path = Path(data_path).absolute().parent.as_posix()
|
||||
m.from_dir(parent_path, files)
|
||||
run_module(m)
|
||||
assert len(m.results) == 1
|
||||
|
||||
def test_androidqf_sms_encrypted_password_prompt(self, mocker):
|
||||
data_path = os.path.join(get_artifact_folder(), "androidqf_encrypted")
|
||||
prompt_mock = mocker.patch(
|
||||
"rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD
|
||||
)
|
||||
m = SMS(
|
||||
target_path=data_path,
|
||||
log=logging,
|
||||
module_options={},
|
||||
)
|
||||
files = list_files(data_path)
|
||||
parent_path = Path(data_path).absolute().parent.as_posix()
|
||||
m.from_dir(parent_path, files)
|
||||
run_module(m)
|
||||
assert prompt_mock.call_count == 1
|
||||
assert len(m.results) == 1
|
||||
|
||||
def test_androidqf_sms_encrypted_password_invalid(self, caplog):
|
||||
data_path = os.path.join(get_artifact_folder(), "androidqf_encrypted")
|
||||
with caplog.at_level(logging.CRITICAL):
|
||||
m = SMS(
|
||||
target_path=data_path,
|
||||
log=logging,
|
||||
module_options={"backup_password": "invalid_password"},
|
||||
)
|
||||
files = list_files(data_path)
|
||||
parent_path = Path(data_path).absolute().parent.as_posix()
|
||||
m.from_dir(parent_path, files)
|
||||
run_module(m)
|
||||
assert len(m.results) == 0
|
||||
assert "Invalid backup password" in caplog.text
|
||||
|
||||
def test_androidqf_sms_encrypted_no_interactive(self, caplog):
|
||||
data_path = os.path.join(get_artifact_folder(), "androidqf_encrypted")
|
||||
with caplog.at_level(logging.CRITICAL):
|
||||
m = SMS(
|
||||
target_path=data_path,
|
||||
log=logging,
|
||||
module_options={"interactive": False},
|
||||
)
|
||||
files = list_files(data_path)
|
||||
parent_path = Path(data_path).absolute().parent.as_posix()
|
||||
m.from_dir(parent_path, files)
|
||||
run_module(m)
|
||||
assert len(m.results) == 0
|
||||
assert (
|
||||
"Cannot decrypt backup because interactivity was disabled and the password was not supplied"
|
||||
in caplog.text
|
||||
)
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from mvt.android.modules.bugreport.dumpsys_appops import DumpsysAppops
|
||||
from mvt.android.modules.bugreport.dumpsys_getprop import DumpsysGetProp
|
||||
from mvt.android.modules.bugreport.dumpsys_packages import DumpsysPackages
|
||||
from mvt.android.modules.bugreport.dumpsys_receivers import DumpsysReceivers
|
||||
from mvt.android.modules.bugreport.tombstones import Tombstones
|
||||
from mvt.common.module import run_module
|
||||
|
||||
@@ -53,6 +54,8 @@ class TestBugreportAnalysis:
|
||||
== "com.samsung.android.provider.filterprovider"
|
||||
)
|
||||
assert m.results[1]["package_name"] == "com.instagram.android"
|
||||
assert m.results[0]["installer"] == ""
|
||||
assert m.results[1]["installer"] == "com.android.vending"
|
||||
assert len(m.results[0]["permissions"]) == 4
|
||||
assert len(m.results[1]["permissions"]) == 32
|
||||
|
||||
@@ -60,6 +63,31 @@ class TestBugreportAnalysis:
|
||||
m = self.launch_bug_report_module(DumpsysGetProp)
|
||||
assert len(m.results) == 0
|
||||
|
||||
def test_receivers_match_exact_package_name(self, indicators_factory):
|
||||
intent = "android.intent.action.PHONE_STATE"
|
||||
false_positive = {
|
||||
"package_name": "com.android.phone",
|
||||
"receiver": (
|
||||
"com.android.phone/"
|
||||
"com.android.services.telephony.sip.SipIncomingCallReceiver"
|
||||
),
|
||||
}
|
||||
malicious_receiver = {
|
||||
"package_name": "com.android.services",
|
||||
"receiver": "com.android.services/com.example.SomeReceiver",
|
||||
}
|
||||
module = DumpsysReceivers(
|
||||
results={intent: [false_positive, malicious_receiver]}
|
||||
)
|
||||
module.indicators = indicators_factory(app_ids=["com.android.services"])
|
||||
|
||||
module.check_indicators()
|
||||
|
||||
assert len(module.alertstore.alerts) == 1
|
||||
alert = module.alertstore.alerts[0]
|
||||
assert alert.event == {intent: malicious_receiver}
|
||||
assert alert.matched_indicator.value == "com.android.services"
|
||||
|
||||
def test_tombstones_modules(self):
|
||||
m = self.launch_bug_report_module(Tombstones)
|
||||
assert len(m.results) == 2
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,371 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import click
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.common.cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
BrokenPluginCommand,
|
||||
load_cli_commands_option,
|
||||
register_cli_commands_from_path,
|
||||
register_cli_plugins,
|
||||
register_installed_cli_commands,
|
||||
)
|
||||
|
||||
|
||||
COMMAND_TEMPLATE = """
|
||||
import click
|
||||
|
||||
|
||||
@click.command({name!r})
|
||||
@click.pass_context
|
||||
def cli(ctx):
|
||||
click.echo({message!r})
|
||||
if ctx.obj:
|
||||
click.echo(ctx.obj.get("marker", ""))
|
||||
"""
|
||||
|
||||
|
||||
def _write_command(path, name, message="command ran"):
|
||||
path.write_text(
|
||||
COMMAND_TEMPLATE.format(name=name, message=message),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _make_group():
|
||||
@click.group()
|
||||
@load_cli_commands_option
|
||||
@click.pass_context
|
||||
def group(ctx):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["marker"] = "parent context"
|
||||
|
||||
return group
|
||||
|
||||
|
||||
def _entry_point(name, value, command=None, exception=None, distribution="plugin"):
|
||||
def load():
|
||||
if exception is not None:
|
||||
raise exception
|
||||
return command
|
||||
|
||||
dist = SimpleNamespace(metadata={"Name": distribution}, version="1.0")
|
||||
return SimpleNamespace(name=name, value=value, load=load, dist=dist)
|
||||
|
||||
|
||||
def test_load_command_option_registers_command_before_resolution(tmp_path):
|
||||
command_path = _write_command(tmp_path / "hello.py", "hello")
|
||||
group = _make_group()
|
||||
|
||||
result = CliRunner().invoke(
|
||||
group,
|
||||
["--load-command", str(command_path), "hello"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "command ran" in result.output
|
||||
assert "parent context" in result.output
|
||||
|
||||
|
||||
def test_load_command_option_supports_folders_and_repeated_paths(tmp_path):
|
||||
folder = tmp_path / "commands"
|
||||
folder.mkdir()
|
||||
_write_command(folder / "b.py", "second")
|
||||
_write_command(folder / "a.py", "first")
|
||||
_write_command(folder / ".hidden.py", "hidden")
|
||||
_write_command(folder / "__init__.py", "init")
|
||||
other = _write_command(tmp_path / "third.py", "third")
|
||||
group = _make_group()
|
||||
|
||||
result = CliRunner().invoke(
|
||||
group,
|
||||
[
|
||||
"--load-command",
|
||||
str(folder),
|
||||
"--load-command",
|
||||
str(other),
|
||||
"--load-command",
|
||||
str(other),
|
||||
"--help",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "first" in result.output
|
||||
assert "second" in result.output
|
||||
assert "third" in result.output
|
||||
assert "hidden" not in result.output
|
||||
assert "init" not in result.output
|
||||
|
||||
|
||||
def test_loaded_command_participates_in_shell_completion(tmp_path):
|
||||
command_path = _write_command(tmp_path / "hello.py", "hello")
|
||||
group = _make_group()
|
||||
words = f"group --load-command {command_path} he"
|
||||
|
||||
result = CliRunner().invoke(
|
||||
group,
|
||||
[],
|
||||
env={
|
||||
"_GROUP_COMPLETE": "bash_complete",
|
||||
"COMP_WORDS": words,
|
||||
"COMP_CWORD": "3",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "plain,hello" in result.output
|
||||
|
||||
|
||||
def test_explicit_command_import_and_contract_failures_are_usage_errors(tmp_path):
|
||||
broken_path = tmp_path / "broken.py"
|
||||
broken_path.write_text("raise RuntimeError('broken import')", encoding="utf-8")
|
||||
missing_cli_path = tmp_path / "missing_cli.py"
|
||||
missing_cli_path.write_text("value = 1", encoding="utf-8")
|
||||
|
||||
broken_result = CliRunner().invoke(
|
||||
_make_group(),
|
||||
["--load-command", str(broken_path), "broken"],
|
||||
)
|
||||
missing_cli_result = CliRunner().invoke(
|
||||
_make_group(),
|
||||
["--load-command", str(missing_cli_path), "missing-cli"],
|
||||
)
|
||||
|
||||
assert broken_result.exit_code == 2
|
||||
assert "broken import" in broken_result.output
|
||||
assert missing_cli_result.exit_code == 2
|
||||
assert "must export a Click command or group named 'cli'" in (
|
||||
missing_cli_result.output
|
||||
)
|
||||
|
||||
|
||||
def test_environment_command_failure_gets_broken_placeholder(tmp_path):
|
||||
command_path = tmp_path / "broken_command.py"
|
||||
command_path.write_text("raise RuntimeError('broken import')", encoding="utf-8")
|
||||
group = click.Group()
|
||||
|
||||
registered = register_cli_commands_from_path(group, command_path)
|
||||
|
||||
assert registered == ["broken-command"]
|
||||
assert isinstance(group.commands["broken-command"], BrokenPluginCommand)
|
||||
result = CliRunner().invoke(group, ["broken-command"])
|
||||
assert result.exit_code == 1
|
||||
assert "broken import" in result.output
|
||||
assert str(command_path) in result.output
|
||||
|
||||
|
||||
def test_environment_command_system_exit_gets_broken_placeholder(tmp_path):
|
||||
command_path = tmp_path / "exiting_command.py"
|
||||
command_path.write_text("raise SystemExit(7)", encoding="utf-8")
|
||||
group = click.Group()
|
||||
|
||||
registered = register_cli_commands_from_path(group, command_path)
|
||||
|
||||
assert registered == ["exiting-command"]
|
||||
assert isinstance(group.commands["exiting-command"], BrokenPluginCommand)
|
||||
result = CliRunner().invoke(group, ["exiting-command"])
|
||||
assert result.exit_code == 1
|
||||
assert "Unable to import custom command" in result.output
|
||||
assert result.output.rstrip().endswith(": 7")
|
||||
|
||||
|
||||
def test_installed_entry_point_name_is_the_command_name(monkeypatch):
|
||||
@click.command("internal-name")
|
||||
def command():
|
||||
click.echo("installed command ran")
|
||||
|
||||
entry_point = _entry_point(
|
||||
"external-name",
|
||||
"example_plugin:cli",
|
||||
command=command,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
lambda **kwargs: [entry_point],
|
||||
)
|
||||
group = click.Group()
|
||||
|
||||
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
||||
|
||||
assert registered == ["external-name"]
|
||||
assert "internal-name" not in group.commands
|
||||
result = CliRunner().invoke(group, ["external-name"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "installed command ran\n"
|
||||
|
||||
|
||||
def test_broken_installed_plugin_does_not_break_cli(monkeypatch):
|
||||
broken = _entry_point(
|
||||
"broken",
|
||||
"broken_plugin:cli",
|
||||
exception=RuntimeError("missing dependency"),
|
||||
distribution="broken-plugin",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
lambda **kwargs: [broken],
|
||||
)
|
||||
group = click.Group()
|
||||
|
||||
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
||||
|
||||
help_result = CliRunner().invoke(group, ["--help"])
|
||||
assert help_result.exit_code == 0
|
||||
assert "Warning: external command could not be loaded." in help_result.output
|
||||
|
||||
result = CliRunner().invoke(group, ["broken"])
|
||||
assert result.exit_code == 1
|
||||
assert "broken-plugin 1.0 (broken_plugin:cli)" in result.output
|
||||
assert "RuntimeError: missing dependency" in result.output
|
||||
|
||||
|
||||
def test_installed_plugin_system_exit_does_not_break_cli(monkeypatch):
|
||||
exiting = _entry_point(
|
||||
"exiting",
|
||||
"exiting_plugin:cli",
|
||||
exception=SystemExit(7),
|
||||
distribution="exiting-plugin",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
lambda **kwargs: [exiting],
|
||||
)
|
||||
group = click.Group()
|
||||
|
||||
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
||||
|
||||
help_result = CliRunner().invoke(group, ["--help"])
|
||||
assert help_result.exit_code == 0
|
||||
result = CliRunner().invoke(group, ["exiting"])
|
||||
assert result.exit_code == 1
|
||||
assert "SystemExit: 7" in result.output
|
||||
|
||||
|
||||
def test_non_click_entry_point_gets_broken_placeholder(monkeypatch):
|
||||
invalid = _entry_point("invalid", "plugin:value", command=object())
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
lambda **kwargs: [invalid],
|
||||
)
|
||||
group = click.Group()
|
||||
|
||||
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
||||
|
||||
assert isinstance(group.commands["invalid"], BrokenPluginCommand)
|
||||
result = CliRunner().invoke(group, ["invalid"])
|
||||
assert result.exit_code == 1
|
||||
assert "must resolve to a Click command or group" in result.output
|
||||
|
||||
|
||||
def test_entry_point_discovery_failure_does_not_break_group(monkeypatch, caplog):
|
||||
def fail_discovery(**kwargs):
|
||||
raise RuntimeError("invalid package metadata")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
fail_discovery,
|
||||
)
|
||||
group = click.Group()
|
||||
|
||||
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
||||
|
||||
assert registered == []
|
||||
assert not group.commands
|
||||
assert "Unable to discover external commands" in caplog.text
|
||||
assert "invalid package metadata" in caplog.text
|
||||
|
||||
|
||||
def test_builtin_and_first_external_command_win_collisions(monkeypatch, caplog):
|
||||
@click.command("version")
|
||||
def core_version():
|
||||
pass
|
||||
|
||||
@click.command()
|
||||
def first():
|
||||
pass
|
||||
|
||||
@click.command()
|
||||
def second():
|
||||
pass
|
||||
|
||||
entry_points = [
|
||||
_entry_point("duplicate", "z_plugin:cli", command=second, distribution="z"),
|
||||
_entry_point("version", "plugin:version", command=first),
|
||||
_entry_point("duplicate", "a_plugin:cli", command=first, distribution="a"),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
lambda **kwargs: entry_points,
|
||||
)
|
||||
group = click.Group(commands={"version": core_version})
|
||||
|
||||
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
||||
|
||||
assert registered == ["duplicate"]
|
||||
assert group.commands["version"] is core_version
|
||||
assert group.commands["duplicate"] is first
|
||||
assert "the command name is already registered" in caplog.text
|
||||
|
||||
|
||||
def test_explicit_command_cannot_replace_existing_command(tmp_path):
|
||||
command_path = _write_command(tmp_path / "version.py", "version")
|
||||
group = _make_group()
|
||||
|
||||
@group.command("version")
|
||||
def core_version():
|
||||
pass
|
||||
|
||||
result = CliRunner().invoke(
|
||||
group,
|
||||
["--load-command", str(command_path), "version"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "the command name is already registered" in result.output
|
||||
|
||||
|
||||
def test_platform_entry_point_groups_and_environment_paths_are_separate(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
ios_path = _write_command(tmp_path / "ios.py", "ios-file")
|
||||
android_path = _write_command(tmp_path / "android.py", "android-file")
|
||||
|
||||
@click.command()
|
||||
def ios_package():
|
||||
pass
|
||||
|
||||
@click.command()
|
||||
def android_package():
|
||||
pass
|
||||
|
||||
def entry_points(*, group):
|
||||
if group == IOS_CLI_PLUGIN_GROUP:
|
||||
return [_entry_point("ios-package", "ios_plugin:cli", ios_package)]
|
||||
return [_entry_point("android-package", "android_plugin:cli", android_package)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
entry_points,
|
||||
)
|
||||
monkeypatch.setenv("TEST_IOS_COMMANDS", str(ios_path))
|
||||
monkeypatch.setenv("TEST_ANDROID_COMMANDS", str(android_path))
|
||||
ios_group = click.Group()
|
||||
android_group = click.Group()
|
||||
|
||||
register_cli_plugins(
|
||||
ios_group,
|
||||
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
||||
environment_variable="TEST_IOS_COMMANDS",
|
||||
)
|
||||
register_cli_plugins(
|
||||
android_group,
|
||||
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
|
||||
environment_variable="TEST_ANDROID_COMMANDS",
|
||||
)
|
||||
|
||||
assert set(ios_group.commands) == {"ios-file", "ios-package"}
|
||||
assert set(android_group.commands) == {"android-file", "android-package"}
|
||||
@@ -4,11 +4,85 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
class RecordingModule(MVTModule):
|
||||
run_order: list[str] = []
|
||||
|
||||
def run(self):
|
||||
self.run_order.append(self.__class__.__name__)
|
||||
|
||||
def check_indicators(self):
|
||||
pass
|
||||
|
||||
|
||||
class FirstModule(RecordingModule):
|
||||
def run(self):
|
||||
super().run()
|
||||
self.results = ["first"]
|
||||
|
||||
|
||||
class SecondModule(RecordingModule):
|
||||
dependencies = (FirstModule,)
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
self.results = self.get_dependency_results(FirstModule) + ["second"]
|
||||
|
||||
|
||||
class ThirdModule(RecordingModule):
|
||||
dependencies = (SecondModule,)
|
||||
|
||||
|
||||
class IndependentModule(RecordingModule):
|
||||
pass
|
||||
|
||||
|
||||
class URLRecordingModule(RecordingModule):
|
||||
def collect_url_results(self):
|
||||
self.add_url_result(
|
||||
"https://example.org/message",
|
||||
"2026-07-29 12:00:00.000000",
|
||||
"test-chat",
|
||||
)
|
||||
|
||||
|
||||
class CustomIOSBackupModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
|
||||
|
||||
class CustomIOSFSModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-fs"),)
|
||||
|
||||
|
||||
class UnscopedCustomModule(RecordingModule):
|
||||
pass
|
||||
|
||||
|
||||
class CustomDependsOnBuiltin(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
dependencies = (FirstModule,)
|
||||
|
||||
|
||||
class RecordingCommand(Command):
|
||||
def init(self):
|
||||
self.initialized = True
|
||||
|
||||
def module_init(self, module):
|
||||
pass
|
||||
|
||||
def finish(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestCommand:
|
||||
def setup_method(self):
|
||||
RecordingModule.run_order = []
|
||||
|
||||
def test_store_alerts_handles_bytes(self, tmp_path):
|
||||
cmd = Command(results_path=str(tmp_path))
|
||||
cmd.alertstore.medium(
|
||||
@@ -21,3 +95,128 @@ class TestCommand:
|
||||
|
||||
alerts = json.loads((tmp_path / "alerts.json").read_text())
|
||||
assert alerts[0]["event"]["payload"] == "\\xa8\\xa9"
|
||||
|
||||
def test_stores_collected_urls(self, tmp_path):
|
||||
cmd = RecordingCommand(results_path=str(tmp_path))
|
||||
cmd.modules = [URLRecordingModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert json.loads((tmp_path / "urls.json").read_text()) == [
|
||||
{
|
||||
"url": "https://example.org/message",
|
||||
"expanded_url": None,
|
||||
"timestamp": "2026-07-29 12:00:00.000000",
|
||||
"source": "test-chat",
|
||||
}
|
||||
]
|
||||
|
||||
def test_modules_run_in_stable_topological_order(self):
|
||||
cmd = RecordingCommand()
|
||||
cmd.modules = [ThirdModule, IndependentModule, SecondModule, FirstModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == [
|
||||
"IndependentModule",
|
||||
"FirstModule",
|
||||
"SecondModule",
|
||||
"ThirdModule",
|
||||
]
|
||||
second = next(module for module in cmd.executed if isinstance(module, SecondModule))
|
||||
assert second.results == ["first", "second"]
|
||||
|
||||
def test_selected_module_runs_transitive_dependencies(self):
|
||||
cmd = RecordingCommand(module_name="ThirdModule")
|
||||
cmd.modules = [ThirdModule, SecondModule, FirstModule, IndependentModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == [
|
||||
"FirstModule",
|
||||
"SecondModule",
|
||||
"ThirdModule",
|
||||
]
|
||||
|
||||
def test_circular_dependency_warns_and_stops(self, caplog):
|
||||
class CircularOne(RecordingModule):
|
||||
pass
|
||||
|
||||
class CircularTwo(RecordingModule):
|
||||
dependencies = (CircularOne,)
|
||||
|
||||
CircularOne.dependencies = (CircularTwo,)
|
||||
|
||||
cmd = RecordingCommand()
|
||||
cmd.modules = [CircularOne, CircularTwo]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == []
|
||||
assert not hasattr(cmd, "initialized")
|
||||
assert "Circular module dependency detected" in caplog.text
|
||||
|
||||
def test_unavailable_dependency_warns_and_stops(self, caplog):
|
||||
class UnavailableModule(RecordingModule):
|
||||
pass
|
||||
|
||||
class DependentModule(RecordingModule):
|
||||
dependencies = (UnavailableModule,)
|
||||
|
||||
cmd = RecordingCommand()
|
||||
cmd.modules = [DependentModule]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == []
|
||||
assert not hasattr(cmd, "initialized")
|
||||
assert "depends on unavailable module UnavailableModule" in caplog.text
|
||||
|
||||
def test_custom_modules_are_filtered_before_ordering(self):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [
|
||||
CustomIOSBackupModule,
|
||||
CustomIOSFSModule,
|
||||
UnscopedCustomModule,
|
||||
]
|
||||
|
||||
assert [module.__name__ for module in cmd._ordered_modules()] == [
|
||||
"FirstModule",
|
||||
"CustomIOSBackupModule",
|
||||
]
|
||||
|
||||
def test_selected_custom_module_runs(self):
|
||||
cmd = RecordingCommand(module_name="CustomIOSBackupModule")
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.custom_modules = [CustomIOSBackupModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["CustomIOSBackupModule"]
|
||||
|
||||
def test_selected_unsupported_custom_module_does_not_run(self):
|
||||
cmd = RecordingCommand(module_name="CustomIOSFSModule")
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.custom_modules = [CustomIOSFSModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == []
|
||||
|
||||
def test_custom_module_dependencies_use_topological_order(self):
|
||||
cmd = RecordingCommand(module_name="CustomDependsOnBuiltin")
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [SecondModule, FirstModule]
|
||||
cmd.custom_modules = [CustomDependsOnBuiltin]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["FirstModule", "CustomDependsOnBuiltin"]
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
import requests
|
||||
|
||||
from mvt.common.config import settings
|
||||
from mvt.common.indicators import Indicators
|
||||
@@ -80,6 +82,136 @@ class TestIndicators:
|
||||
assert ind.check_url("https://198.51.100.1:8080/")
|
||||
assert ind.check_url("https://1.1.1.1/") is None
|
||||
|
||||
def test_google_maps_short_url_is_not_resolved(self, indicator_file, mocker):
|
||||
head_request = mocker.patch("mvt.common.url.requests.head")
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([indicator_file], load_default=False)
|
||||
|
||||
assert ind.check_url("https://goo.gl/maps/example") is None
|
||||
head_request.assert_not_called()
|
||||
|
||||
def test_check_url_batches_preserves_order(self, indicator_file):
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([indicator_file], load_default=False)
|
||||
|
||||
matches = ind.check_url_batches(
|
||||
[
|
||||
[
|
||||
"https://github.com",
|
||||
"http://example.com/thisisbad",
|
||||
"https://www.example.org/foobar",
|
||||
],
|
||||
["https://github.com", "https://www.example.org/foobar"],
|
||||
[],
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
assert matches[0]
|
||||
assert matches[0].ioc.value == "http://example.com/thisisbad"
|
||||
assert matches[1]
|
||||
assert matches[1].ioc.value == "example.org"
|
||||
assert matches[2] is None
|
||||
assert matches[3] is None
|
||||
|
||||
def test_check_url_batches_deduplicates_and_limits_workers(
|
||||
self, indicator_file, mocker
|
||||
):
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([indicator_file], load_default=False)
|
||||
mocker.patch("mvt.common.indicators.URL_CHECK_MAX_WORKERS", 2)
|
||||
|
||||
barrier = threading.Barrier(2)
|
||||
lock = threading.Lock()
|
||||
calls = []
|
||||
active = 0
|
||||
max_active = 0
|
||||
|
||||
def head_request(url, timeout):
|
||||
nonlocal active, max_active
|
||||
with lock:
|
||||
calls.append(url)
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
call_number = len(calls)
|
||||
|
||||
try:
|
||||
if call_number <= 2:
|
||||
barrier.wait(timeout=5)
|
||||
return mocker.Mock(status_code=200, headers={})
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
|
||||
mocker.patch("mvt.common.url.requests.head", side_effect=head_request)
|
||||
urls = [
|
||||
"https://bit.ly/one",
|
||||
"https://tinyurl.com/two",
|
||||
"https://t.co/three",
|
||||
]
|
||||
|
||||
assert ind.check_url_batches([urls, [urls[0]]]) == [None, None]
|
||||
assert sorted(calls) == sorted(urls)
|
||||
assert max_active == 2
|
||||
|
||||
def test_check_url_batches_respects_disabled_network(
|
||||
self, indicator_file, mocker
|
||||
):
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([indicator_file], load_default=False)
|
||||
mocker.patch("mvt.common.indicators.settings.NETWORK_ACCESS_ALLOWED", False)
|
||||
head_request = mocker.patch("mvt.common.url.requests.head")
|
||||
|
||||
assert ind.check_url_batches([["https://bit.ly/example"]]) == [None]
|
||||
head_request.assert_not_called()
|
||||
|
||||
def test_check_url_batches_handles_nested_redirects_and_request_failures(
|
||||
self, indicator_file, mocker
|
||||
):
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([indicator_file], load_default=False)
|
||||
|
||||
def head_request(url, timeout):
|
||||
if url == "https://bit.ly/failure":
|
||||
raise requests.Timeout()
|
||||
if url == "https://tinyurl.com/nested":
|
||||
return mocker.Mock(
|
||||
status_code=301,
|
||||
headers={"Location": "https://t.co/nested"},
|
||||
)
|
||||
if url == "https://t.co/nested":
|
||||
return mocker.Mock(
|
||||
status_code=302,
|
||||
headers={"Location": "https://www.example.org/landing"},
|
||||
)
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
head = mocker.patch(
|
||||
"mvt.common.url.requests.head", side_effect=head_request
|
||||
)
|
||||
|
||||
matches = ind.check_url_batches(
|
||||
[["https://bit.ly/failure"], ["https://tinyurl.com/nested"]]
|
||||
)
|
||||
|
||||
assert matches[0] is None
|
||||
assert matches[1]
|
||||
assert matches[1].ioc.value == "example.org"
|
||||
assert (
|
||||
ind.get_expanded_url("https://tinyurl.com/nested")
|
||||
== "https://www.example.org/landing"
|
||||
)
|
||||
assert (
|
||||
ind.get_expanded_url("https://t.co/nested")
|
||||
== "https://www.example.org/landing"
|
||||
)
|
||||
assert ind.get_expanded_url("https://bit.ly/failure") is None
|
||||
assert {call.args[0] for call in head.call_args_list} == {
|
||||
"https://bit.ly/failure",
|
||||
"https://tinyurl.com/nested",
|
||||
"https://t.co/nested",
|
||||
}
|
||||
|
||||
def test_check_file_hash(self, indicator_file):
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([indicator_file], load_default=False)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import pytest
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.common.module_loader import (
|
||||
CustomModuleLoadError,
|
||||
get_module_logger,
|
||||
load_custom_modules,
|
||||
load_custom_modules_from_path,
|
||||
module_supports_command,
|
||||
)
|
||||
from mvt.ios.modules.mixed.whatsapp import Whatsapp
|
||||
|
||||
|
||||
MODULE_TEMPLATE = """
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
class {name}(MVTModule):
|
||||
supported_commands = {supported_commands!r}
|
||||
|
||||
def run(self):
|
||||
pass
|
||||
|
||||
def check_indicators(self):
|
||||
pass
|
||||
|
||||
def serialize(self, result):
|
||||
return None
|
||||
"""
|
||||
|
||||
|
||||
def _write_module(path, name, supported_commands=()):
|
||||
path.write_text(
|
||||
MODULE_TEMPLATE.format(
|
||||
name=name,
|
||||
supported_commands=supported_commands,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_load_custom_modules_from_python_file(tmp_path):
|
||||
module_path = _write_module(tmp_path / "custom.py", "FileModule")
|
||||
|
||||
modules = load_custom_modules_from_path(str(module_path))
|
||||
|
||||
assert [module.__name__ for module in modules] == ["FileModule"]
|
||||
assert issubclass(modules[0], MVTModule)
|
||||
|
||||
|
||||
def test_load_custom_modules_from_folder_in_sorted_order(tmp_path):
|
||||
_write_module(tmp_path / "b_module.py", "BModule")
|
||||
_write_module(tmp_path / "a_module.py", "AModule")
|
||||
_write_module(tmp_path / ".hidden.py", "HiddenModule")
|
||||
_write_module(tmp_path / "__init__.py", "InitModule")
|
||||
nested = tmp_path / "nested"
|
||||
nested.mkdir()
|
||||
_write_module(nested / "nested_module.py", "NestedModule")
|
||||
|
||||
modules = load_custom_modules_from_path(str(tmp_path))
|
||||
|
||||
assert [module.__name__ for module in modules] == ["AModule", "BModule"]
|
||||
|
||||
|
||||
def test_discovery_ignores_imported_base_and_unrelated_classes(tmp_path):
|
||||
module_path = tmp_path / "custom.py"
|
||||
module_path.write_text(
|
||||
"""
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
class Unrelated:
|
||||
pass
|
||||
|
||||
|
||||
class DiscoveredModule(MVTModule):
|
||||
def run(self):
|
||||
pass
|
||||
|
||||
def check_indicators(self):
|
||||
pass
|
||||
|
||||
def serialize(self, result):
|
||||
return None
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
modules = load_custom_modules_from_path(str(module_path))
|
||||
|
||||
assert [module.__name__ for module in modules] == ["DiscoveredModule"]
|
||||
|
||||
|
||||
def test_load_custom_modules_deduplicates_same_class(tmp_path):
|
||||
module_path = _write_module(tmp_path / "custom.py", "DuplicateModule")
|
||||
|
||||
modules = load_custom_modules([str(module_path), str(module_path)])
|
||||
|
||||
assert [module.__name__ for module in modules] == ["DuplicateModule"]
|
||||
|
||||
|
||||
def test_load_custom_modules_raises_for_missing_path(tmp_path):
|
||||
with pytest.raises(CustomModuleLoadError, match="does not exist"):
|
||||
load_custom_modules_from_path(str(tmp_path / "missing.py"))
|
||||
|
||||
|
||||
def test_load_custom_modules_raises_for_import_error(tmp_path):
|
||||
module_path = tmp_path / "broken.py"
|
||||
module_path.write_text("raise RuntimeError('broken import')", encoding="utf-8")
|
||||
|
||||
with pytest.raises(CustomModuleLoadError, match="broken import"):
|
||||
load_custom_modules_from_path(str(module_path))
|
||||
|
||||
|
||||
def test_load_custom_modules_loads_env_folder_first(tmp_path, monkeypatch):
|
||||
env_folder = tmp_path / "env"
|
||||
env_folder.mkdir()
|
||||
cli_folder = tmp_path / "cli"
|
||||
cli_folder.mkdir()
|
||||
_write_module(env_folder / "env_module.py", "EnvModule")
|
||||
_write_module(cli_folder / "cli_module.py", "CliModule")
|
||||
monkeypatch.setenv("MVT_CUSTOM_MODULES", str(env_folder))
|
||||
|
||||
modules = load_custom_modules([str(cli_folder)])
|
||||
|
||||
assert [module.__name__ for module in modules] == ["EnvModule", "CliModule"]
|
||||
|
||||
|
||||
def test_module_supports_command_requires_explicit_declaration(tmp_path, caplog):
|
||||
module_path = _write_module(tmp_path / "custom.py", "DefaultModule")
|
||||
module = load_custom_modules_from_path(str(module_path))[0]
|
||||
|
||||
assert not module_supports_command(module, "ios", "check-backup")
|
||||
assert not module_supports_command(module, "android", "check-bugreport")
|
||||
assert "DefaultModule has no supported_commands" in caplog.text
|
||||
|
||||
|
||||
def test_module_supports_command_honors_supported_commands(tmp_path):
|
||||
module_path = _write_module(
|
||||
tmp_path / "custom.py",
|
||||
"SpecificModule",
|
||||
(("ios", "check-backup"),),
|
||||
)
|
||||
module = load_custom_modules_from_path(str(module_path))[0]
|
||||
|
||||
assert module_supports_command(module, "ios", "check-backup")
|
||||
assert not module_supports_command(module, "ios", "check-fs")
|
||||
|
||||
|
||||
def test_get_module_logger_keeps_builtin_names():
|
||||
assert get_module_logger(Whatsapp).name == "mvt.ios.modules.mixed.whatsapp"
|
||||
|
||||
|
||||
def test_get_module_logger_parents_package_modules_under_mvt_ext():
|
||||
class PackageModule(MVTModule):
|
||||
pass
|
||||
|
||||
PackageModule.__module__ = "some_plugin_package.ios.custom"
|
||||
|
||||
assert (
|
||||
get_module_logger(PackageModule).name
|
||||
== "mvt.ext.some_plugin_package.ios.custom"
|
||||
)
|
||||
|
||||
|
||||
def test_get_module_logger_strips_the_plugin_package_prefix():
|
||||
class PluginModule(MVTModule):
|
||||
pass
|
||||
|
||||
PluginModule.__module__ = "mvt_plugin_amnesty_custom.ios.custom"
|
||||
|
||||
assert get_module_logger(PluginModule).name == "mvt.ext.amnesty_custom.ios.custom"
|
||||
|
||||
|
||||
def test_get_module_logger_only_strips_the_prefix_from_the_top_level():
|
||||
class NestedModule(MVTModule):
|
||||
pass
|
||||
|
||||
NestedModule.__module__ = "other_package.mvt_plugin_sub"
|
||||
|
||||
assert get_module_logger(NestedModule).name == "mvt.ext.other_package.mvt_plugin_sub"
|
||||
|
||||
|
||||
def test_get_module_logger_names_path_modules_after_their_file(tmp_path):
|
||||
module_path = _write_module(tmp_path / "my_custom_module.py", "PathModule")
|
||||
module = load_custom_modules_from_path(str(module_path))[0]
|
||||
|
||||
assert get_module_logger(module).name == "mvt.ext.my_custom_module"
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user