mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-22 15:18:26 +02:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca063a2ddc | ||
|
|
ece44a7e7f | ||
|
|
27cfe7f1e7 | ||
|
|
531d63ab07 | ||
|
|
b18afb4a48 | ||
|
|
32c88822a0 | ||
|
|
23565af4c1 | ||
|
|
7d41d0646e | ||
|
|
24c65859ee | ||
|
|
a11319dcc5 | ||
|
|
c9a57f5d10 | ||
|
|
ee24121a73 | ||
|
|
b06b5c36ac | ||
|
|
00d892d354 | ||
|
|
47ac8a5a85 | ||
|
|
0b5b3f2d7c | ||
|
|
85adb02eb9 | ||
|
|
91da901741 | ||
|
|
dcfd500112 | ||
|
|
a78894aaa5 | ||
|
|
24645cb718 | ||
|
|
104ffb167f | ||
|
|
097766a63b | ||
|
|
65b8114469 | ||
|
|
874f75bb4c | ||
|
|
3c8a581fd0 | ||
|
|
65df483258 | ||
|
|
fd27fe3378 | ||
|
|
7ef26779bf | ||
|
|
3a43f6fcc7 | ||
|
|
45fdf85790 | ||
|
|
627ab32d42 | ||
|
|
8b85972cc3 | ||
|
|
f747751b09 | ||
|
|
832e46604d | ||
|
|
a3c8b56102 | ||
|
|
e9bf197ec6 | ||
|
|
288b313649 | ||
|
|
87a27f6e37 | ||
|
|
1bf2f9b35a | ||
|
|
b904414bb0 | ||
|
|
008844b440 | ||
|
|
ab879bb23b | ||
|
|
dc0650bf76 | ||
|
|
dac4acb180 | ||
|
|
dd8bd2cb01 | ||
|
|
30c11f68c7 | ||
|
|
a92ec7f963 | ||
|
|
10a5bccbe0 | ||
|
|
3b21c9347c | ||
|
|
0fdc2c34b0 | ||
|
|
545ac19158 | ||
|
|
2de8312349 | ||
|
|
0ee25edf0a | ||
|
|
fa24b5465b | ||
|
|
683b8ba133 | ||
|
|
7b64463727 | ||
|
|
47fd771b2a | ||
|
|
8123db26d9 | ||
|
|
d92a60c9be | ||
|
|
0b48d9fe1d | ||
|
|
067f053627 | ||
|
|
93b7fb5232 | ||
|
|
8617e0bf54 | ||
|
|
53fb12aee8 | ||
|
|
8b195d2696 | ||
|
|
39a26d0f0b |
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
|
||||
|
||||
@@ -11,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,12 @@ jobs:
|
||||
tag-suffix: "-android"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # the package version comes from the git tags
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
- 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 +49,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 +61,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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1" # Mondays, 09:00 UTC
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
if: github.ref_type != 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: |
|
||||
last=$(git describe --tags --abbrev=0 --match 'v*')
|
||||
if [ "$(git rev-list "$last..HEAD" --count)" = 0 ]; then
|
||||
echo "Nothing merged since $last"; exit 0
|
||||
fi
|
||||
tag=v$(date -u +%Y.%-m.%-d)
|
||||
gh release create "$tag" --target "$GITHUB_SHA" --generate-notes
|
||||
# Events made with GITHUB_TOKEN don't start other workflows; run them by hand.
|
||||
gh workflow run release.yml --ref "$tag"
|
||||
gh workflow run publish-release-docker.yml --ref "$tag"
|
||||
|
||||
publish:
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # PyPI trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: astral-sh/setup-uv@v10.0.0
|
||||
- run: uv build && uv publish
|
||||
@@ -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:
|
||||
|
||||
+14
-2
@@ -119,12 +119,24 @@ ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
adb \
|
||||
binutils \
|
||||
default-jre-headless \
|
||||
file \
|
||||
git \
|
||||
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 +156,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" ]
|
||||
@@ -32,8 +32,3 @@ clean:
|
||||
dist:
|
||||
$(UV) build
|
||||
|
||||
upload:
|
||||
$(UV) tool run twine upload dist/*
|
||||
|
||||
test-upload:
|
||||
$(UV) tool run twine upload --repository testpypi dist/*
|
||||
@@ -58,29 +58,33 @@ For alternative installation options and known issues, please refer to the [docu
|
||||
|
||||
## Usage
|
||||
|
||||
MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
|
||||
MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion`, `plugins` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
|
||||
|
||||
Pass `--verbose` to any of the three commands, before the command name (`mvt-ios --verbose check-backup ...`), for debug output. The `--verbose` option the `check-*` commands accept after their name still works but is kept for compatibility only and will be removed in a future release.
|
||||
|
||||
### Shell completion
|
||||
|
||||
MVT can generate shell completion scripts for Bash, Zsh, and Fish:
|
||||
MVT can generate a shell completion script for Bash, Zsh, and Fish which covers `mvt`, `mvt-ios` and `mvt-android`:
|
||||
|
||||
```bash
|
||||
mvt-ios completion
|
||||
mvt-android completion
|
||||
mvt completion
|
||||
```
|
||||
|
||||
The commands print setup instructions by default. To generate a completion script directly, pass the shell name:
|
||||
The command prints setup instructions by default. To generate the completion script directly, pass the shell name:
|
||||
|
||||
```bash
|
||||
mvt-ios completion bash
|
||||
mvt-android completion zsh
|
||||
mvt completion bash
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Plugin packages extend MVT with additional forensic modules, which run inside
|
||||
the `check-*` commands, and with top-level commands on `mvt`, `mvt-ios` and
|
||||
`mvt-android`. See the
|
||||
[development documentation](https://docs.mvt.re/en/latest/development/) for
|
||||
details.
|
||||
writing and installing them, and the
|
||||
[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
|
||||
for the entry points a package registers commands in.
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -58,7 +58,7 @@ mvt-android check-intrusion-logs --output /path/to/results/ /path/to/intrusion-l
|
||||
| `-l, --list-modules` | List the available intrusion-log modules and exit. |
|
||||
| `-m, --module NAME` | Run a single module (e.g. `DnsEvent`) instead of all of them. |
|
||||
| `-t, --timezone TZ` | IANA timezone name for the device (e.g. `Europe/Paris`). When set, event timestamps are converted to the device's local time instead of UTC. |
|
||||
| `-v, --verbose` | Verbose logging. |
|
||||
| `-v, --verbose` | Verbose logging. Kept for compatibility and to be removed in a future release: pass `--verbose` to `mvt-android` itself instead. |
|
||||
|
||||
## Modules
|
||||
|
||||
|
||||
+22
-22
@@ -6,61 +6,61 @@ Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fi
|
||||
|
||||
To enable it, you need to register a completion script with your shell, which varies depending on the shell you are using.
|
||||
|
||||
The following describes how to generate the command completion scripts and add them to your shell configuration.
|
||||
`mvt completion` generates one script which covers `mvt`, `mvt-ios` and `mvt-android`. The following describes how to generate that script and add it to your shell configuration.
|
||||
|
||||
> **Note: You will need to start a new shell for the changes to take effect.**
|
||||
|
||||
### For Bash
|
||||
|
||||
```bash
|
||||
# Generate bash completion scripts
|
||||
mvt-ios completion bash > ~/.mvt-ios-complete.bash
|
||||
mvt-android completion bash > ~/.mvt-android-complete.bash
|
||||
# Generate the bash completion script
|
||||
mvt completion bash > ~/.mvt-complete.bash
|
||||
```
|
||||
|
||||
Add the following to `~/.bashrc`:
|
||||
```bash
|
||||
# source mvt completion scripts
|
||||
[ -f ~/.mvt-ios-complete.bash ] && . ~/.mvt-ios-complete.bash
|
||||
[ -f ~/.mvt-android-complete.bash ] && . ~/.mvt-android-complete.bash
|
||||
# source the mvt completion script
|
||||
[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash
|
||||
```
|
||||
|
||||
### For Zsh
|
||||
|
||||
```bash
|
||||
# Generate zsh completion scripts
|
||||
mvt-ios completion zsh > ~/.mvt-ios-complete.zsh
|
||||
mvt-android completion zsh > ~/.mvt-android-complete.zsh
|
||||
# Generate the zsh completion script
|
||||
mvt completion zsh > ~/.mvt-complete.zsh
|
||||
```
|
||||
|
||||
Add the following to `~/.zshrc`:
|
||||
```bash
|
||||
# source mvt completion scripts
|
||||
[ -f ~/.mvt-ios-complete.zsh ] && . ~/.mvt-ios-complete.zsh
|
||||
[ -f ~/.mvt-android-complete.zsh ] && . ~/.mvt-android-complete.zsh
|
||||
# source the mvt completion script
|
||||
[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-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
|
||||
# Generate the fish completion script
|
||||
mkdir -p ~/.config/fish/conf.d
|
||||
mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish
|
||||
```
|
||||
|
||||
Fish loads completion files from `~/.config/fish/completions` automatically.
|
||||
Fish loads the files in `~/.config/fish/conf.d` 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
|
||||
mvt completion bash --install
|
||||
```
|
||||
|
||||
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/completions`.
|
||||
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/conf.d` and changes no shell configuration.
|
||||
|
||||
!!! note
|
||||
|
||||
Earlier versions generated one script per command, with `mvt-ios completion`
|
||||
and `mvt-android completion`. Files written by them keep working. When you
|
||||
switch to the single script, remove the old files and the lines which load
|
||||
them from your shell configuration.
|
||||
|
||||
For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion).
|
||||
|
||||
@@ -1,131 +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.
|
||||
|
||||
## 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)}]
|
||||
```
|
||||
|
||||
## 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,202 @@
|
||||
# Custom CLI Commands
|
||||
|
||||
MVT can load additional top-level commands into `mvt`, `mvt-ios` and
|
||||
`mvt-android`. A command package chooses which of the three each of its
|
||||
commands is added to.
|
||||
Custom commands are different from [custom forensic modules](index.md#custom-modules):
|
||||
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 on one or more of the MVT CLIs.
|
||||
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}")
|
||||
```
|
||||
|
||||
Log through `get_plugin_logger(__name__)` from `mvt.plugin`. Records logged
|
||||
through `logging.getLogger(__name__)` do not reach `command.log`, and MVT's
|
||||
console handler does not show them.
|
||||
|
||||
Register the object in the package's `pyproject.toml`. The entry-point name is
|
||||
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"
|
||||
```
|
||||
|
||||
Each entry-point group adds the command to one CLI: `mvt.ios.cli_plugins` to
|
||||
`mvt-ios`, `mvt.android.cli_plugins` to `mvt-android` and `mvt.cli_plugins` to
|
||||
`mvt`. Register the command in the group of every CLI which should offer it: a
|
||||
platform-specific command belongs in one platform group, and a command which
|
||||
handles acquisitions of both platforms, as above, in both. After installing the
|
||||
package in the same environment as MVT, the command appears directly in those
|
||||
CLIs:
|
||||
|
||||
```bash
|
||||
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. `mvt plugins list` shows the installed packages and
|
||||
the commands they add, see [Managing Plugins](plugins.md).
|
||||
|
||||
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`.
|
||||
|
||||
### Commands on `mvt`
|
||||
|
||||
A MVT plugin command can also add sub-commands to the base `mvt` command. This can be used for commands which are not tied to a particular forensic platform:
|
||||
|
||||
```toml
|
||||
[project.entry-points."mvt.cli_plugins"]
|
||||
my-plugin = "my_mvt_plugin:my_plugin"
|
||||
```
|
||||
|
||||
Commands in this group are added to `mvt` only, so this one is invoked as
|
||||
`mvt my-plugin`. A command on `mvt` has nothing but its name to say which
|
||||
plugin it belongs to, so name it after the plugin, and make it a Click group
|
||||
when the plugin has several operations to offer, such as
|
||||
`mvt my-plugin configure`.
|
||||
|
||||
## Developing a Command Locally
|
||||
|
||||
A package is how a command is distributed. While a command is being written,
|
||||
MVT can load it straight from its file instead, so the package need not be
|
||||
reinstalled after every change; an editable install of the package does the
|
||||
same through its entry points. Create a Python file that exports one Click
|
||||
command or group named `cli`:
|
||||
|
||||
```python
|
||||
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 environment variable of
|
||||
the CLI the commands belong on. Like the entry-point groups, each variable adds
|
||||
its commands to one CLI only:
|
||||
|
||||
```bash
|
||||
export MVT_CUSTOM_COMMANDS=./commands
|
||||
export MVT_IOS_CUSTOM_COMMANDS=./ios_commands
|
||||
export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands
|
||||
```
|
||||
|
||||
## Building a Module-Running Command
|
||||
|
||||
A command which runs forensic modules over an acquisition subclasses `Command`.
|
||||
`Command` creates the output folder and writes `command.log`. It orders the
|
||||
modules, resolves their dependencies and runs them. It writes the result files,
|
||||
`alerts.json` and `info.json`. The subclass sets `platform`, `name` and
|
||||
`modules`:
|
||||
|
||||
```python
|
||||
from mvt.plugin import Command, MVTModule, convert_unix_to_iso, get_plugin_logger
|
||||
|
||||
log = get_plugin_logger(__name__)
|
||||
|
||||
|
||||
class APKManifest(MVTModule):
|
||||
supported_commands = (("android", "check-apks"),)
|
||||
|
||||
def run(self):
|
||||
self.results = [{"checked_at": convert_unix_to_iso(0)}]
|
||||
|
||||
|
||||
class CmdCheckAPKs(Command):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.platform = "android"
|
||||
self.name = "check-apks"
|
||||
self.modules = [APKManifest]
|
||||
```
|
||||
|
||||
`platform` and `name` are the pair the modules declare in
|
||||
`supported_commands`. `modules` lists the module classes the command runs,
|
||||
imported directly. A command which runs modules of another plugin depends on
|
||||
that package in `pyproject.toml` and imports them the same way. `init()`,
|
||||
`module_init(module)` and `finish()` are optional hooks. `run()` calls them
|
||||
before the run, before each module and after the run.
|
||||
|
||||
Wrap the command in a Click command with an `--output` option. Run it, then
|
||||
print the alert summary:
|
||||
|
||||
```python
|
||||
import click
|
||||
|
||||
|
||||
@click.command("check-apks")
|
||||
@click.option("--output", "-o", type=click.Path(exists=False))
|
||||
@click.argument("TARGET_PATH", type=click.Path(exists=True))
|
||||
def cli(output, target_path):
|
||||
cmd = CmdCheckAPKs(target_path=target_path, results_path=output)
|
||||
log.info("Checking APK files at path: %s", target_path)
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
```
|
||||
|
||||
The `--verbose` option of `mvt`, `mvt-ios` and `mvt-android` applies to the
|
||||
command. The command defines no `--verbose` option of its own. The pair a
|
||||
plugin command adds is not listed anywhere in MVT. Name it in the plugin's
|
||||
README.
|
||||
|
||||
## Naming and Errors
|
||||
|
||||
Built-in MVT commands cannot be replaced. External command names must also be
|
||||
unique on each CLI; when installed packages or environment paths collide, MVT
|
||||
keeps the first command and logs a warning. The environment path of a CLI is
|
||||
registered before its installed packages, so a command loaded from there wins a
|
||||
collision with a package. A collision from an explicit `--load-command` is a
|
||||
usage error.
|
||||
|
||||
A package entry point or environment command that cannot be imported appears
|
||||
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,391 @@
|
||||
# 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.
|
||||
|
||||
A module can only depend on modules the command it runs in also has. When a
|
||||
declared dependency is not among them, the command logs a warning naming the
|
||||
module and the missing dependency, skips that module and everything depending
|
||||
on it, and runs the rest of the analysis. Selecting such a module with
|
||||
`--module` therefore leaves nothing to run, which the warning explains.
|
||||
|
||||
A cycle in the dependency graph is a programming error rather than a
|
||||
configuration problem: the command logs a warning and runs no modules at all.
|
||||
|
||||
## Custom modules
|
||||
|
||||
MVT's module-running `check-*` commands can run forensic modules which are not
|
||||
part of MVT. Custom modules are distributed as plugin packages. A Python
|
||||
package installed next to MVT registers its modules through an entry point.
|
||||
The modules then load automatically in every command they support, see
|
||||
[Installed module packages](#installed-module-packages). `mvt plugins list`
|
||||
shows the installed packages and where each one was installed from.
|
||||
|
||||
MVT can also load module files by path, with `--load-module` and
|
||||
`MVT_CUSTOM_MODULES`, see
|
||||
[Developing modules locally](#developing-modules-locally). This can be used
|
||||
while writing a module. Use a Python package to distribute one.
|
||||
|
||||
A custom module declares in `supported_commands` the platform and command
|
||||
pairs it runs in. A module with empty `supported_commands` does not run and
|
||||
MVT logs a warning. The nine 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")
|
||||
```
|
||||
|
||||
`check-iocs` re-checks stored results rather than an acquisition. It matches
|
||||
every `<slug>.json` file in the results folder to the module with that slug.
|
||||
It then runs that module's `check_indicators()` again. A module which
|
||||
implements `check_indicators()` is included in `check-iocs` for its platform.
|
||||
It does not need to declare the `check-iocs` pair.
|
||||
|
||||
### Writing a module
|
||||
|
||||
A module subclasses `MVTModule` or one of the base classes below and
|
||||
implements `run()`. `check_indicators()` and `serialize()` are optional. The
|
||||
first matches results against IOCs or detections. The second returns timeline
|
||||
records.
|
||||
|
||||
```python
|
||||
from mvt.plugin import IOSExtraction, convert_unix_to_iso
|
||||
|
||||
|
||||
class ExampleCustomModule(IOSExtraction):
|
||||
supported_commands = (
|
||||
("ios", "check-backup"),
|
||||
("ios", "check-fs"),
|
||||
)
|
||||
slug = "example_custom_module"
|
||||
|
||||
def run(self):
|
||||
self.results = [{"checked_at": convert_unix_to_iso(0)}]
|
||||
|
||||
def check_indicators(self):
|
||||
pass
|
||||
|
||||
def serialize(self, result):
|
||||
return None
|
||||
```
|
||||
|
||||
The base classes are:
|
||||
|
||||
- `MVTModule`: the base of every module. It provides `self.results`,
|
||||
`self.alertstore`, `self.log`, `self.indicators` and
|
||||
`get_dependency_results()`. Subclass it directly for a module which reads
|
||||
only the results of other modules.
|
||||
- `IOSExtraction`: `("ios", "check-backup")` and `("ios", "check-fs")`. Adds
|
||||
`_find_ios_database()`, which locates a module's database in a backup or in
|
||||
a filesystem dump and repairs it if it is malformed. Adds
|
||||
`_get_backup_files_from_manifest()`, `_get_backup_file_from_id()` and
|
||||
`_get_fs_files_from_patterns()`. Adds `_open_sqlite_db()`, which opens a
|
||||
database read-only.
|
||||
- `SysdiagnoseExtraction`: `("ios", "check-sysdiagnose")`. MVT extracts the
|
||||
archive and calls `from_sysdiagnose_folder()` before `run()`. The module
|
||||
reads files with `_get_files_by_pattern()` and `_get_file_content()`.
|
||||
`ips_files` lists the crash reports. See
|
||||
[Check an iOS Sysdiagnose](../ios/sysdiagnose.md).
|
||||
- `AndroidQFModule`: `("android", "check-androidqf")`. MVT calls `from_dir()`
|
||||
or `from_zip()` with the file list of the acquisition. The module reads
|
||||
files with `_get_files_by_pattern()` and `_get_file_content()`.
|
||||
`_get_device_timezone()` returns the device timezone.
|
||||
- `AndroidBackupModule`: `("android", "check-backup")`. MVT calls `from_dir()`
|
||||
or `from_ab()`. The module reads files with `_get_files_by_pattern()` and
|
||||
`_get_file_content()`.
|
||||
- `BugReportModule`: `("android", "check-bugreport")`. MVT calls `from_dir()`
|
||||
or `from_zip()`. The module reads files with `_get_files_by_pattern()`,
|
||||
`_get_files_by_patterns()` and `_get_file_content()`.
|
||||
`_get_dumpstate_file()` returns the dumpstate file, and
|
||||
`_get_file_modification_time()` the modification time of a file.
|
||||
|
||||
The underscore-named helpers are internal to the base classes. Plugin modules
|
||||
can call them. Their names and signatures can change between releases. Read
|
||||
the base class in `src/mvt/ios/modules` or `src/mvt/android/modules` before
|
||||
relying on one.
|
||||
|
||||
### Depending on a built-in module
|
||||
|
||||
A module which post-processes records generated by one or more built-in MVT
|
||||
modules must declare the source modules in `dependencies`. It reads their
|
||||
results with `get_dependency_results()`. Import the class from its family
|
||||
package: `mvt.ios.modules.backup`, `mvt.ios.modules.fs`,
|
||||
`mvt.ios.modules.mixed`, `mvt.android.modules.androidqf`,
|
||||
`mvt.android.modules.backup`, `mvt.android.modules.bugreport` or
|
||||
`mvt.android.modules.intrusion_logs`.
|
||||
|
||||
```python
|
||||
from mvt.ios.modules.backup import Manifest
|
||||
from mvt.plugin import MVTModule
|
||||
|
||||
|
||||
class DependentCustomModule(MVTModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
dependencies = (Manifest,)
|
||||
|
||||
def run(self):
|
||||
manifest_results = self.get_dependency_results(Manifest)
|
||||
self.results = [{"manifest_entries": len(manifest_results)}]
|
||||
```
|
||||
|
||||
Dependencies are ordered as for the built-in modules, with custom modules
|
||||
appended after the built-ins, see [Module dependencies](#module-dependencies).
|
||||
A dependency has to run in every command the module supports. Where it does
|
||||
not, MVT skips the module with a warning.
|
||||
|
||||
`get_dependency_results()` returns the plain dictionaries the module produced.
|
||||
They are the same records it writes to `<slug>.json`. Typed results per module
|
||||
are planned.
|
||||
|
||||
### Replacing a built-in module
|
||||
|
||||
A custom module which extends a built-in one runs alongside it, and when the
|
||||
two share a slug they write to the same results file. MVT warns about that
|
||||
collision, naming both modules, where each came from and the file the later one
|
||||
overwrites, but it runs them both. Set `replaces` to the class the module
|
||||
supersedes to take that module's place instead: when both are available to a
|
||||
command, the named module is dropped from the run.
|
||||
|
||||
```python
|
||||
from mvt.ios.modules.backup import Manifest as BuiltinManifest
|
||||
|
||||
|
||||
class Manifest(BuiltinManifest):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = BuiltinManifest
|
||||
```
|
||||
|
||||
Keeping the class name of the replaced module, as above, keeps the name
|
||||
`--module` selects the module by and the slug its results file is named after.
|
||||
A replacement with a different class name writes to a file named after its own
|
||||
slug, unless it sets `slug` to the slug of the module it replaces, and
|
||||
`--module` still selects it by the name of the replaced module, which MVT logs.
|
||||
Taking over the slug of a replaced module is not a collision and is not warned
|
||||
about, because that module is no longer part of the run.
|
||||
|
||||
Modules which depend on the replaced module receive the replacement instead, so
|
||||
`get_dependency_results(BuiltinManifest)` returns the replacement's results. A
|
||||
module must not depend on the module it replaces: that module does not run, so
|
||||
the replacement has to produce the data itself, and MVT logs a warning when a
|
||||
module declares both.
|
||||
|
||||
A replacement takes on the obligations of the module it replaces. If it
|
||||
declares a dependency the command does not provide, it is skipped like any
|
||||
other module with an unavailable dependency, and the module it replaced stays
|
||||
out of the run: neither of them produces results, and the modules depending on
|
||||
the replaced module are skipped as well.
|
||||
|
||||
Subclassing the replaced module is not required, but a replacement which is not
|
||||
a subclass is logged with a warning, because its results may not be what the
|
||||
modules depending on the replaced module expect. Naming a module the command
|
||||
does not run has no effect, and modules which replace each other in a cycle all
|
||||
keep running and replace nothing. Every applied substitution is logged, so it
|
||||
is recorded in `command.log` when the command runs with an `--output` folder.
|
||||
|
||||
Every command resolves replacements on its own. `check-iocs` matches stored
|
||||
results files against the slugs of the modules available for that command. A
|
||||
replacement which subclasses the module it replaces inherits its
|
||||
`check_indicators()`. It is then part of `check-iocs` for its platform and
|
||||
re-checks the results file named after its slug. A replacement with no
|
||||
`check_indicators()` is not part of `check-iocs`. The built-in module it
|
||||
replaced re-checks the file. `check-iocs` matches `--module` on the class name
|
||||
only. Pass a differently named replacement's own name there, not the name of
|
||||
the module it replaces.
|
||||
|
||||
### Importing from MVT
|
||||
|
||||
Import from `mvt.plugin` if it has what you need. The names it exports are
|
||||
kept working on a best-effort basis. A change to one of them is announced in
|
||||
the release notes. Anything else in `mvt` can be imported too, but may change
|
||||
between releases without notice. The plugin interface is best effort.
|
||||
|
||||
`mvt.plugin` exports the base classes above and `Command`, `Alert` and
|
||||
`AlertLevel`, the result types, `DatabaseNotFoundError` and
|
||||
`DatabaseCorruptedError`, the timestamp converters, the settings API of
|
||||
[Plugin Configuration](plugin_configuration.md), MVT's own `settings`,
|
||||
`get_plugin_logger()` and `MVT_VERSION`. `src/mvt/plugin.py` holds the list.
|
||||
Read MVT's `settings` for values such as `NETWORK_ACCESS_ALLOWED` and
|
||||
`NETWORK_TIMEOUT`. Plugin values go in the plugin's own settings file.
|
||||
|
||||
## Installed module packages
|
||||
|
||||
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-example-org = "mvt_plugin_example_org:get_modules"
|
||||
```
|
||||
|
||||
The entry point must resolve to an iterable of `MVTModule` subclasses, or to
|
||||
a callable returning one:
|
||||
|
||||
```python
|
||||
from mvt.plugin 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]
|
||||
```
|
||||
|
||||
`get_modules()` is the package's module list, written by hand. A package which
|
||||
keeps its modules in separate files imports each class there and lists it. A
|
||||
module missing from the list does not load.
|
||||
|
||||
Installed modules follow the same rules as other custom modules: each module
|
||||
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-example-org
|
||||
```
|
||||
|
||||
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, an organisation's custom modules
|
||||
would be distributed as `mvt-plugin-example-org` with the import package
|
||||
`mvt_plugin_example_org`.
|
||||
|
||||
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_example_org` log as
|
||||
`mvt.ext.example_org.*`.
|
||||
- Files loaded with `--load-module` or `MVT_CUSTOM_MODULES` log as
|
||||
`mvt.ext.<file name>`.
|
||||
|
||||
Outside a module class, for example in a custom command line handler, log
|
||||
through `get_plugin_logger(__name__)`. It returns a logger in the same
|
||||
namespace.
|
||||
|
||||
## Developing modules locally
|
||||
|
||||
While a module is being written, load it from its file. `--load-module` takes a
|
||||
Python file, or a folder of them, on every module-running command, and can be
|
||||
repeated:
|
||||
|
||||
```bash
|
||||
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
|
||||
```
|
||||
|
||||
For a folder, MVT loads its non-hidden top-level `*.py` files in sorted order
|
||||
and skips `__init__.py`. `MVT_CUSTOM_MODULES` names a folder to load on every
|
||||
module-running command, before any `--load-module` path:
|
||||
|
||||
```bash
|
||||
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
|
||||
```
|
||||
|
||||
Files loaded this way follow the same rules as packaged modules.
|
||||
`--list-modules` reports them with the SHA-256 hash of the file in place of a
|
||||
version. An editable install of the package (`pip install -e .`) also works:
|
||||
the modules load through the entry point, and `mvt plugins list` shows the
|
||||
package with the `local` origin.
|
||||
|
||||
Loading by path is for development. Move a module into a package once it
|
||||
works.
|
||||
|
||||
## Auditing loaded modules
|
||||
|
||||
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.
|
||||
- `mvt plugins list` lists the installed packages, where each of them
|
||||
was installed from and how many modules it contributes, see
|
||||
[Managing Plugins](plugins.md).
|
||||
|
||||
## Profiling
|
||||
|
||||
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,157 @@
|
||||
# Plugin Configuration
|
||||
|
||||
Plugin packages that add [custom CLI commands](custom_commands.md) or
|
||||
[modules](index.md#installed-module-packages) often need to store their
|
||||
own settings, such as an API key, a server URL or the timestamp of the last
|
||||
synchronization. MVT provides a namespaced settings base class so each plugin
|
||||
keeps its configuration in its own file, and a data folder for anything else a
|
||||
plugin needs to keep on disk.
|
||||
|
||||
!!! warning
|
||||
|
||||
Do not write plugin settings to MVT's own `config.yaml`. MVT rewrites that
|
||||
file with the settings it knows about every time it starts, so any other
|
||||
section is deleted.
|
||||
|
||||
## Where Settings Are Stored
|
||||
|
||||
Each plugin gets one YAML file in a `plugins` folder next to MVT's own
|
||||
configuration:
|
||||
|
||||
```
|
||||
~/.config/mvt/plugins/<plugin name>.yaml
|
||||
```
|
||||
|
||||
The exact parent folder follows the platform convention used for MVT's
|
||||
`config.yaml` (for example `~/Library/Application Support/mvt` on macOS). Use
|
||||
`plugin_config_path()` from `mvt.plugin` instead of building the path by hand.
|
||||
|
||||
Plugin names must be lowercase and may only contain letters, digits and dashes,
|
||||
matching the `mvt-plugin-<name>` package naming convention. MVT creates the
|
||||
`plugins` folder with `0700` permissions and writes the settings files with
|
||||
`0600` permissions, because they commonly hold credentials. Files are written
|
||||
through a temporary file and moved into place, so an interrupted save never
|
||||
leaves a partially written settings file behind.
|
||||
|
||||
## Plugin Data Folder
|
||||
|
||||
Everything else a plugin keeps on disk, such as a cache, a downloaded artifact
|
||||
or synchronization state, belongs in the folder returned by the `data_folder()`
|
||||
class method of the plugin's settings class, or by `plugin_data_folder()` from
|
||||
`mvt.plugin`, called with the plugin name if the plugin has no settings class:
|
||||
|
||||
```
|
||||
~/.local/share/mvt/plugin-data/<plugin name>/ # Linux
|
||||
~/Library/Application Support/mvt/plugin-data/<plugin name>/ # macOS
|
||||
```
|
||||
|
||||
The folder sits beside MVT's own data, such as the downloaded indicators. It is
|
||||
created if it is missing, with `0700` permissions. Asking for it again returns
|
||||
the same path and leaves the contents alone, so a plugin can ask for it every
|
||||
time it needs the folder. `ExamplePluginSettings` below is the settings class
|
||||
defined in the next section:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
|
||||
def cache_path() -> str:
|
||||
folder = ExamplePluginSettings.data_folder()
|
||||
return os.path.join(folder, "virustotal_lookups_cache.json")
|
||||
```
|
||||
|
||||
A plugin which has no settings class calls
|
||||
`plugin_data_folder("example-plugin")` instead.
|
||||
|
||||
Do not fall back on a path of your own such as `~/.cache/example-plugin`: it
|
||||
is a Linux-only convention, and MVT will not create it for you.
|
||||
|
||||
## Defining Plugin Settings
|
||||
|
||||
Subclass `MVTPluginSettings`, set `plugin_name` and declare typed fields with
|
||||
defaults:
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
|
||||
from mvt.plugin import MVTPluginSettings
|
||||
|
||||
|
||||
class ExamplePluginSettings(MVTPluginSettings):
|
||||
plugin_name = "example-plugin"
|
||||
|
||||
API_KEY: Optional[str] = None
|
||||
MAX_RESULTS: int = 25
|
||||
LAST_SYNC: Optional[str] = None
|
||||
```
|
||||
|
||||
`load()` returns the current settings and `save()` writes them back:
|
||||
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import click
|
||||
|
||||
from mvt.plugin import plugin_env_prefix
|
||||
|
||||
|
||||
def sync():
|
||||
settings = ExamplePluginSettings.load()
|
||||
if not settings.API_KEY:
|
||||
prefix = plugin_env_prefix(settings.plugin_name)
|
||||
raise click.ClickException(
|
||||
f"No API key configured. Set {prefix}API_KEY or "
|
||||
"run 'example-plugin configure'."
|
||||
)
|
||||
|
||||
settings.LAST_SYNC = datetime.now(timezone.utc).isoformat()
|
||||
settings.save()
|
||||
```
|
||||
|
||||
The message builds the variable name with `plugin_env_prefix()`, see
|
||||
[Environment Variables](#environment-variables).
|
||||
|
||||
A missing settings file is not an error: the plugin then runs on the field
|
||||
defaults and on whatever the environment provides. `save()` only persists the
|
||||
values that differ from the defaults, and it never touches MVT's `config.yaml`.
|
||||
A settings file that cannot be parsed, or that does not hold a mapping of
|
||||
setting names to values, raises a `PluginConfigLoadError` naming the file.
|
||||
|
||||
Every subclass that sets its own `plugin_name` gets its own file and its own
|
||||
environment namespace. A subclass that does not redefine `plugin_name` inherits
|
||||
it, and therefore shares the file and the environment variables of its parent
|
||||
class.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Every field can also be set with an environment variable. The prefix is
|
||||
`MVT_PLUGIN_`, followed by the plugin name upper-cased with dashes replaced by
|
||||
underscores, followed by the field name. For the example above:
|
||||
|
||||
```bash
|
||||
export MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY=...
|
||||
export MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS=50
|
||||
```
|
||||
|
||||
Do not repeat the plugin name in a field name: a plugin named `example-scanner`
|
||||
with a `SCANNER_API_KEY` field asks the user for
|
||||
`MVT_PLUGIN_EXAMPLE_SCANNER_SCANNER_API_KEY`. Name the field `API_KEY`.
|
||||
|
||||
Settings resolve in this order, from highest to lowest priority:
|
||||
|
||||
1. Arguments passed to the settings class directly, such as
|
||||
`ExamplePluginSettings(API_KEY="...")`
|
||||
2. Environment variables
|
||||
3. The plugin's YAML file
|
||||
4. The field defaults declared on the settings class
|
||||
|
||||
!!! tip
|
||||
|
||||
On shared or multi-user machines, prefer passing API keys through
|
||||
environment variables rather than saving them to the plugin file. `save()`
|
||||
skips every value that the environment currently supplies, so a credential
|
||||
provided that way is not copied into the settings file when a plugin saves
|
||||
an unrelated setting.
|
||||
|
||||
Unknown keys in a plugin's YAML file are ignored, so a settings file written by
|
||||
a newer version of a plugin does not break an older one.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Managing Plugins
|
||||
|
||||
Plugin packages extend MVT with additional
|
||||
[forensic modules](index.md#custom-modules) and
|
||||
[CLI commands](custom_commands.md). Because installed packages load
|
||||
automatically, `mvt plugins` audits what is installed and checks whether
|
||||
updates are available. The command lives on `mvt` only, although the packages
|
||||
it lists extend `mvt-ios` and `mvt-android` too.
|
||||
|
||||
## List Installed Plugins
|
||||
|
||||
```bash
|
||||
mvt plugins list
|
||||
```
|
||||
|
||||
```
|
||||
Installed MVT plugins
|
||||
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┓
|
||||
┃ Name ┃ Version ┃ Origin ┃ Modules ┃ Commands ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━┩
|
||||
│ mvt-plugin-example │ 1.2.0 │ pypi │ 4 │ summarize │
|
||||
│ mvt-plugin-research │ 0.1.0 │ git+3f9a1c7d │ 2 │ - │
|
||||
│ mvt-plugin-local │ 0.0.1 │ local │ 1 │ triage │
|
||||
└─────────────────────┴─────────┴──────────────┴─────────┴───────────┘
|
||||
```
|
||||
|
||||
The origin records where each package was installed from: `pypi` for a package
|
||||
installed from a package index, `git+<commit>` for a package installed directly
|
||||
from a repository, and `local` for a package installed from a local folder or
|
||||
archive rather than from an index, including an editable development install.
|
||||
The last two columns show how many forensic modules the package contributes and
|
||||
which CLI commands it adds.
|
||||
|
||||
A plugin whose modules cannot be imported is listed with `error` in the
|
||||
`Modules` column rather than breaking the listing.
|
||||
|
||||
## Check for Updates
|
||||
|
||||
```bash
|
||||
mvt plugins check-updates
|
||||
```
|
||||
|
||||
```
|
||||
Plugin updates available:
|
||||
mvt-plugin-example 1.2.0 → 1.3.0
|
||||
Upgrade with: pip install -U mvt-plugin-example
|
||||
|
||||
MVT does not install plugin updates. Run the command above when you decide to
|
||||
upgrade.
|
||||
```
|
||||
|
||||
Packages installed from a package index are compared against the latest release
|
||||
published for them. A package which was never published, for example a plugin
|
||||
distributed only within an organization, is skipped silently.
|
||||
|
||||
!!! note
|
||||
|
||||
Packages shown with the `pypi` origin are compared against
|
||||
[PyPI](https://pypi.org), whichever index they were installed from. A
|
||||
plugin installed from a private index under a name which also exists on
|
||||
PyPI is therefore compared against the unrelated public package of that
|
||||
name. Give plugins published to a private index a name which is not taken
|
||||
on PyPI, and treat an unexpected update suggestion as a reason to check
|
||||
where the package would come from.
|
||||
|
||||
!!! warning
|
||||
|
||||
MVT never installs or upgrades a plugin itself, it only prints the command
|
||||
which does. Upgrading a plugin in the middle of an investigation changes
|
||||
the modules producing the results, and a plugin runs as trusted code inside
|
||||
the MVT process, so pulling in a new version is a decision for the analyst
|
||||
to make deliberately and not a side effect of running a check.
|
||||
|
||||
## Automatic Update Checks
|
||||
|
||||
MVT also reports available plugin updates in the banner printed when a command
|
||||
starts:
|
||||
|
||||
```
|
||||
MVT - Mobile Verification Toolkit
|
||||
|
||||
https://mvt.re
|
||||
Version: 2026.7.29
|
||||
|
||||
Plugin updates available:
|
||||
mvt-plugin-example 1.2.0 → 1.3.0 (pip install -U mvt-plugin-example)
|
||||
```
|
||||
|
||||
This check runs at most once every 12 hours. In between checks MVT prints the
|
||||
findings of the latest check without contacting anything, so a plugin update
|
||||
stays visible without a lookup on every command. The
|
||||
`mvt plugins check-updates` command checks immediately, regardless of when the
|
||||
last check happened.
|
||||
|
||||
The automatic check is skipped when the `--disable-update-check` option is
|
||||
used, when `NETWORK_ACCESS_ALLOWED` is disabled in the MVT configuration, and
|
||||
when no plugins are installed.
|
||||
|
||||
## Plugins Installed From a Repository
|
||||
|
||||
A plugin installed with `pip install "mvt-plugin-example @ git+<url>"` is
|
||||
checked by asking the remote repository which commit the installed revision
|
||||
points at now. MVT runs git and ssh in batch mode, so a repository which needs
|
||||
credentials MVT does not already have fails the check instead of prompting for
|
||||
them. The check is skipped silently when git is not available, when the
|
||||
repository cannot be reached, and when access to it is denied.
|
||||
|
||||
How the plugin was installed decides what an update means:
|
||||
|
||||
- A plugin installed from a branch is reported as outdated when the branch has
|
||||
moved past the installed commit.
|
||||
- A plugin installed from a specific commit or a tag is pinned. It is never
|
||||
reported as outdated, however far the branch it came from moves on.
|
||||
|
||||
Pinning a plugin to a commit or a tag is therefore the way to keep the modules
|
||||
used across an investigation stable.
|
||||
+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`.
|
||||
+1
-1
@@ -9,7 +9,7 @@ Mobile Verification Toolkit (MVT) is a tool to facilitate the [consensual forens
|
||||
It has been developed and released by the [Amnesty International Security Lab](https://securitylab.amnesty.org) in July 2021 in the context of the [Pegasus Project](https://forbiddenstories.org/about-the-pegasus-project/) along with [a technical forensic methodology](https://www.amnesty.org/en/latest/research/2021/07/forensic-methodology-report-how-to-catch-nso-groups-pegasus/). It continues to be maintained by Amnesty International and other contributors.
|
||||
|
||||
|
||||
In this documentation you will find instructions on how to install and run the `mvt-ios` and `mvt-android` commands, and guidance on how to interpret the extracted results.
|
||||
In this documentation you will find instructions on how to install and run the `mvt-ios`, `mvt-android` and `mvt` commands, and guidance on how to interpret the extracted results.
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
+3
-3
@@ -64,7 +64,7 @@ It is recommended to try installing and running MVT from [Windows Subsystem Linu
|
||||
pipx install mvt
|
||||
```
|
||||
|
||||
You now should have the `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window.
|
||||
You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window.
|
||||
|
||||
### Installing from PyPI directly into a virtual environment
|
||||
You can use `pipenv`, `poetry` etc. for your virtual environment, but the provided example is with the built-in `venv` tool:
|
||||
@@ -84,7 +84,7 @@ source env/bin/activate
|
||||
pip install mvt
|
||||
```
|
||||
|
||||
The `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active.
|
||||
The `mvt`, `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active.
|
||||
|
||||
### Installing from git source with pipx
|
||||
If you want to have the latest features in development, you can install MVT directly from the source code in git.
|
||||
@@ -93,7 +93,7 @@ If you want to have the latest features in development, you can install MVT dire
|
||||
pipx install --force git+https://github.com/mvt-project/mvt.git
|
||||
```
|
||||
|
||||
You now should have the `mvt-ios` and `mvt-android` utilities installed.
|
||||
You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed.
|
||||
|
||||
**Notes:**
|
||||
1. The `--force` flag is necessary to force the reinstallation of the package.
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis
|
||||
- [This repository](https://github.com/Te-k/stalkerware-indicators) contains IOCs for Android stalkerware including [a STIX MVT-compatible file](https://raw.githubusercontent.com/Te-k/stalkerware-indicators/master/generated/stalkerware.stix2).
|
||||
- We are also maintaining [a list of IOCs](https://github.com/mvt-project/mvt-indicators) in STIX format from public spyware campaigns.
|
||||
|
||||
You can automaticallly download the latest public indicator files with the command `mvt-ios download-iocs` or `mvt-android download-iocs`. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT.
|
||||
You can automatically download the latest public indicator files with the command `mvt download-iocs`. The per-platform forms `mvt-ios download-iocs` and `mvt-android download-iocs` do the same thing. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT.
|
||||
|
||||
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.
|
||||
|
||||
+18
-14
@@ -1,30 +1,32 @@
|
||||
# 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.
|
||||
custom MVT modules. MVT does not include built-in sysdiagnose modules. The
|
||||
command runs the modules of the installed
|
||||
[plugin packages](../development/index.md#installed-module-packages) which
|
||||
declare support for it. Install at least one such package first.
|
||||
|
||||
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 \
|
||||
mvt-ios check-sysdiagnose --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.
|
||||
`--list-modules` to display the eligible 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`.
|
||||
Extend `SysdiagnoseExtraction` from `mvt.plugin`, see
|
||||
[Writing a module](../development/index.md#writing-a-module). The module reads
|
||||
the archive the same way whether MVT was given a folder or a tar archive. It
|
||||
declares the command in `supported_commands`. While writing one,
|
||||
[load it from its file](../development/index.md#developing-modules-locally).
|
||||
|
||||
```python
|
||||
from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
|
||||
from mvt.plugin import SysdiagnoseExtraction
|
||||
|
||||
|
||||
class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
|
||||
@@ -44,7 +46,9 @@ class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
|
||||
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`.
|
||||
MVT extracts a tar archive first. It calls `from_sysdiagnose_folder()` on each
|
||||
module before `run()`. `ips_files` lists the IPS crash reports.
|
||||
|
||||
`_get_files_by_pattern()` and `_get_file_content()` are internal helpers of the
|
||||
base class. Use them to read the archive. Their names and signatures can change
|
||||
between releases. See `src/mvt/ios/modules/sysdiagnose/base.py`.
|
||||
+5
-1
@@ -49,5 +49,9 @@ nav:
|
||||
- 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"
|
||||
- Managing Plugins: "development/plugins.md"
|
||||
- License: "license.md"
|
||||
+22
-19
@@ -17,25 +17,26 @@ classifiers = [
|
||||
"Programming Language :: Python",
|
||||
]
|
||||
dependencies = [
|
||||
"click==8.3.3",
|
||||
"rich==14.3.3",
|
||||
"click==8.4.2",
|
||||
"rich==15.0.0",
|
||||
"tld==0.13.2",
|
||||
"requests==2.33.1",
|
||||
"simplejson==3.20.2",
|
||||
"packaging==26.2",
|
||||
"requests==2.34.2",
|
||||
"simplejson==4.1.1",
|
||||
"packaging==26.3",
|
||||
"appdirs==1.4.4",
|
||||
"iOSbackup==0.9.925",
|
||||
"iphone_backup_decrypt==0.9.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
"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.3.1",
|
||||
"betterproto2==0.9.1",
|
||||
"pydantic==2.13.3",
|
||||
"pydantic-settings==2.14.0",
|
||||
"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"
|
||||
|
||||
@@ -44,8 +45,9 @@ homepage = "https://docs.mvt.re/en/latest/"
|
||||
repository = "https://github.com/mvt-project/mvt"
|
||||
|
||||
[project.scripts]
|
||||
mvt-ios = "mvt.ios:cli"
|
||||
mvt-android = "mvt.android:cli"
|
||||
mvt = "mvt.cli:main"
|
||||
mvt-ios = "mvt.ios:main"
|
||||
mvt-android = "mvt.android:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -65,13 +67,13 @@ 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]
|
||||
requires = ["setuptools>=61.0"]
|
||||
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.coverage.run]
|
||||
@@ -120,5 +122,6 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
mvt = ["ios/data/*.json"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "mvt.common.version.MVT_VERSION" }
|
||||
[tool.setuptools_scm]
|
||||
# The version is the latest v* tag; ignore the archive/* tags.
|
||||
git_describe_command = "git describe --dirty --tags --long --match 'v*'"
|
||||
@@ -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/
|
||||
|
||||
from .cli import cli
|
||||
from .cli import cli, main
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
@@ -20,10 +21,10 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
self.alertstore.medium(
|
||||
f'Found accessibility service: "{result["service"]}"',
|
||||
f'Found accessibility service: "{result["component"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
)
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
"""
|
||||
@@ -33,41 +34,69 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
:param content: content of the accessibility section (string)
|
||||
"""
|
||||
|
||||
# "Old" syntax
|
||||
in_services = False
|
||||
self.results: list[dict[str, Any]] = []
|
||||
services: dict[tuple[int | None, str], dict] = {}
|
||||
user_id: int | None = None
|
||||
state: str | None = None
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("installed services:"):
|
||||
in_services = True
|
||||
continue
|
||||
user_match = re.search(r"attributes:\{id=(\d+)", line)
|
||||
if user_match:
|
||||
user_id = int(user_match.group(1))
|
||||
|
||||
if not in_services:
|
||||
continue
|
||||
|
||||
if line.strip() == "}":
|
||||
# At end of installed services
|
||||
break
|
||||
|
||||
service = line.split(":")[1].strip()
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"package_name": service.split("/")[0],
|
||||
"service": service,
|
||||
}
|
||||
stripped = line.strip()
|
||||
state_match = re.match(
|
||||
r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)",
|
||||
stripped,
|
||||
)
|
||||
|
||||
# "New" syntax - AOSP >= 14 (?)
|
||||
# Looks like:
|
||||
# Enabled services:{{com.azure.authenticator/com.microsoft.brooklyn.module.accessibility.BrooklynAccessibilityService}, {com.agilebits.onepassword/com.agilebits.onepassword.filling.accessibility.FillingAccessibilityService}}
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("Enabled services:"):
|
||||
matches = re.finditer(r"{([^{]+?)}", line)
|
||||
|
||||
for match in matches:
|
||||
# Each match is in format: <package_name>/<service>
|
||||
package_name, _, service = match.group(1).partition("/")
|
||||
|
||||
self.results.append(
|
||||
{"package_name": package_name, "service": service}
|
||||
if state_match:
|
||||
state = state_match.group(1).lower()
|
||||
inline = state_match.group(2)
|
||||
for component in re.findall(
|
||||
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
|
||||
):
|
||||
service = services.setdefault(
|
||||
(user_id, component), self._new_service(component, user_id)
|
||||
)
|
||||
service[self._state_field(state)] = True
|
||||
service["accessibility_tool"] = "(A11yTool)" in inline
|
||||
continue
|
||||
|
||||
if not state:
|
||||
continue
|
||||
if stripped == "}" or stripped.startswith("AccessibilityInputFilter"):
|
||||
state = None
|
||||
continue
|
||||
component_match = re.search(
|
||||
r"(?:\d+\s*:\s*)?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?",
|
||||
stripped,
|
||||
)
|
||||
if component_match:
|
||||
component = component_match.group(1)
|
||||
service = services.setdefault(
|
||||
(user_id, component), self._new_service(component, user_id)
|
||||
)
|
||||
service[self._state_field(state)] = True
|
||||
service["accessibility_tool"] = "(A11yTool)" in stripped
|
||||
|
||||
self.results.extend(services.values())
|
||||
|
||||
@staticmethod
|
||||
def _state_field(state: str) -> str:
|
||||
return {"binding": "binding", "bound": "bound"}.get(state, state)
|
||||
|
||||
@staticmethod
|
||||
def _new_service(component: str, user_id: int | None) -> dict:
|
||||
package_name, service_name = component.split("/", 1)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"component": component,
|
||||
"package_name": package_name,
|
||||
"service_name": service_name,
|
||||
"installed": False,
|
||||
"enabled": False,
|
||||
"binding": False,
|
||||
"bound": False,
|
||||
"crashed": False,
|
||||
"accessibility_tool": False,
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
@@ -98,6 +99,34 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
|
||||
return keystore
|
||||
|
||||
def parse_binary_xml(self, data: bytes) -> list[dict]:
|
||||
"""Recover ADB key records from Android binary XML (ABX).
|
||||
|
||||
Some dumpstate implementations embed ABX in a text stream and replace
|
||||
binary token bytes. The public key remains intact, while unavailable
|
||||
numeric metadata is represented as ``None`` rather than corrupt text.
|
||||
"""
|
||||
keystore = []
|
||||
seen = set()
|
||||
for match in re.finditer(
|
||||
rb"(?<![A-Za-z0-9+/])([A-Za-z0-9+/]{300,}={0,2})"
|
||||
rb"(?: ([A-Za-z0-9_.@-]+))?",
|
||||
data,
|
||||
):
|
||||
key = match.group(1)
|
||||
try:
|
||||
base64.b64decode(key, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
full_key = key + (b" " + match.group(2) if match.group(2) else b"")
|
||||
key_info = self.calculate_key_info(full_key)
|
||||
key_info["last_connected"] = None
|
||||
keystore.append(key_info)
|
||||
return keystore
|
||||
|
||||
@staticmethod
|
||||
def calculate_key_info(user_key: bytes) -> dict:
|
||||
if b" " in user_key:
|
||||
@@ -118,7 +147,7 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
return {
|
||||
"user": user.decode("utf-8"),
|
||||
"fingerprint": key_fingerprint_colon,
|
||||
"key": key_base64,
|
||||
"key": key_base64.decode("ascii", errors="replace"),
|
||||
}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
@@ -175,12 +204,23 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
# Keystore is in XML format on some devices and we need to parse it
|
||||
if keystore_data and keystore_data.startswith(b"<?xml"):
|
||||
parsed["debugging_manager"]["keystore"] = self.parse_xml(keystore_data)
|
||||
elif keystore_data and keystore_data.startswith(b"ABX\x00"):
|
||||
parsed["debugging_manager"]["keystore"] = self.parse_binary_xml(
|
||||
keystore_data
|
||||
)
|
||||
else:
|
||||
# Keystore is not XML format
|
||||
parsed["debugging_manager"]["keystore"] = keystore_data
|
||||
|
||||
parsed = parsed["debugging_manager"]
|
||||
|
||||
for key, value in list(parsed.items()):
|
||||
if isinstance(value, bytes):
|
||||
decoded = value.decode("utf-8", errors="replace")
|
||||
parsed[key] = (
|
||||
decoded == "true" if decoded in ("true", "false") else decoded
|
||||
)
|
||||
|
||||
# Calculate key fingerprints for better readability
|
||||
key_info = []
|
||||
for user_key in parsed.get("user_keys", []):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
@@ -27,14 +28,14 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
for entry in perm["entries"]:
|
||||
if "timestamp" in entry:
|
||||
if entry.get("timestamp"):
|
||||
records.append(
|
||||
{
|
||||
"timestamp": entry["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": entry["access"],
|
||||
"event": entry["event"],
|
||||
"data": f"{result['package_name']} access to "
|
||||
f"{perm['name']}: {entry['access']}",
|
||||
f"{perm['name']}: {entry['event']}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -51,7 +52,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
# We use a placeholder entry to create a basic alert even without permission entries.
|
||||
placeholder_entry = {"access": "Unknown", "timestamp": ""}
|
||||
placeholder_entry = {"event": "unknown", "timestamp": ""}
|
||||
|
||||
for perm in result["permissions"]:
|
||||
if (
|
||||
@@ -60,12 +61,12 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
):
|
||||
for entry in sorted(
|
||||
perm["entries"] or [placeholder_entry],
|
||||
key=lambda x: x["timestamp"],
|
||||
key=lambda x: x.get("timestamp") or "",
|
||||
):
|
||||
cleaned_result = result.copy()
|
||||
cleaned_result["permissions"] = [perm]
|
||||
self.alertstore.medium(
|
||||
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
|
||||
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['event']}' at {entry['timestamp']}",
|
||||
entry["timestamp"],
|
||||
cleaned_result,
|
||||
)
|
||||
@@ -73,111 +74,151 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
elif result["package_name"] in RISKY_PACKAGES:
|
||||
for entry in sorted(
|
||||
perm["entries"] or [placeholder_entry],
|
||||
key=lambda x: x["timestamp"],
|
||||
key=lambda x: x.get("timestamp") or "",
|
||||
):
|
||||
cleaned_result = result.copy()
|
||||
cleaned_result["permissions"] = [perm]
|
||||
self.alertstore.medium(
|
||||
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
|
||||
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['event']}' at {entry['timestamp']}",
|
||||
entry["timestamp"],
|
||||
cleaned_result,
|
||||
)
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
# self.results: List[Dict[str, Any]] = []
|
||||
perm: dict[str, Any] = {}
|
||||
package: dict[str, Any] = {}
|
||||
entry: dict[str, Any] = {}
|
||||
uid = None
|
||||
self.results: list[dict[str, Any]] = []
|
||||
permission: dict[str, Any] | None = None
|
||||
package: dict[str, Any] | None = None
|
||||
uid: str | None = None
|
||||
uid_details: dict[str, Any] = {}
|
||||
attribution: str | None = None
|
||||
in_packages = False
|
||||
|
||||
def finish_permission() -> None:
|
||||
nonlocal permission
|
||||
if package is not None and permission is not None:
|
||||
package["permissions"].append(permission)
|
||||
permission = None
|
||||
|
||||
def finish_package() -> None:
|
||||
nonlocal package
|
||||
finish_permission()
|
||||
if package is not None:
|
||||
self.results.append(package)
|
||||
package = None
|
||||
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" Uid 0:"):
|
||||
uid_match = re.match(r"^ Uid ([^:]+):$", line)
|
||||
if uid_match:
|
||||
in_packages = True
|
||||
finish_package()
|
||||
uid = uid_match.group(1)
|
||||
uid_details = {
|
||||
"uid_state": None,
|
||||
"capability": None,
|
||||
"app_widget_visible": None,
|
||||
"default_modes": {},
|
||||
}
|
||||
continue
|
||||
|
||||
if not in_packages:
|
||||
continue
|
||||
|
||||
if line.startswith(" Uid "):
|
||||
uid = line[6:-1]
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
if package:
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
uid_property = re.match(
|
||||
r"^ (state|capability|appWidgetVisible)=(.*)$", line
|
||||
)
|
||||
if uid_property:
|
||||
key = {
|
||||
"state": "uid_state",
|
||||
"appWidgetVisible": "app_widget_visible",
|
||||
}.get(uid_property.group(1), uid_property.group(1))
|
||||
value: Any = uid_property.group(2)
|
||||
if value in ("true", "false"):
|
||||
value = value == "true"
|
||||
uid_details[key] = value
|
||||
continue
|
||||
|
||||
perm = {}
|
||||
self.results.append(package)
|
||||
package = {}
|
||||
default_mode = re.match(r"^ ([A-Z0-9_]+): mode=([^\s]+)", line)
|
||||
if default_mode and package is None:
|
||||
uid_details["default_modes"][default_mode.group(1)] = (
|
||||
default_mode.group(2)
|
||||
)
|
||||
continue
|
||||
|
||||
if line.startswith(" Package "):
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
|
||||
if package:
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
|
||||
perm = {}
|
||||
self.results.append(package)
|
||||
|
||||
finish_package()
|
||||
package = {
|
||||
"package_name": line[12:-1],
|
||||
"permissions": [],
|
||||
"uid": uid,
|
||||
**uid_details,
|
||||
}
|
||||
continue
|
||||
|
||||
if package and line.startswith(" ") and line[6] != " ":
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
perm = {}
|
||||
|
||||
perm["name"] = line.split()[0]
|
||||
perm["entries"] = []
|
||||
if len(line.split()) > 1:
|
||||
perm["access"] = line.split()[1][1:-2]
|
||||
|
||||
operation_match = re.match(
|
||||
r"^ ([A-Z0-9_]+)(?: \(([^)]+)\))?:\s*$", line
|
||||
)
|
||||
if package is not None and operation_match:
|
||||
finish_permission()
|
||||
permission = {
|
||||
"name": operation_match.group(1),
|
||||
"mode": operation_match.group(2),
|
||||
"entries": [],
|
||||
}
|
||||
attribution = None
|
||||
continue
|
||||
|
||||
if line.startswith(" "):
|
||||
# Permission entry like:
|
||||
# Reject: [fg-s]2021-05-19 22:02:52.054 (-314d1h25m2s33ms)
|
||||
access_type = line.split(":")[0].strip()
|
||||
if access_type not in ["Access", "Reject"]:
|
||||
# Skipping invalid access type. Some entries are not in the format we expect
|
||||
continue
|
||||
attribution_match = re.match(r"^\s{8,}([^=]+)=\[$", line)
|
||||
if attribution_match:
|
||||
attribution = attribution_match.group(1).strip()
|
||||
continue
|
||||
if line.strip() == "]":
|
||||
attribution = None
|
||||
continue
|
||||
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
|
||||
entry["access"] = access_type
|
||||
entry["type"] = line[line.find("[") + 1 : line.find("]")]
|
||||
|
||||
try:
|
||||
entry["timestamp"] = convert_datetime_to_iso(
|
||||
datetime.strptime(
|
||||
line[line.find("]") + 1 : line.find("(")].strip(),
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
)
|
||||
if permission is None:
|
||||
continue
|
||||
event_match = re.match(
|
||||
r"^\s*(Access|Reject):\s*\[([^]]+)\]\s*"
|
||||
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s*"
|
||||
r"(\([^)]*\))?(?:\s+duration=([^\s]+))?",
|
||||
line,
|
||||
)
|
||||
running_match = re.match(
|
||||
r"^\s*Running start at:\s*(\S+(?: \S+)?)",
|
||||
line,
|
||||
)
|
||||
if event_match:
|
||||
entry = {
|
||||
"event": event_match.group(1).lower(),
|
||||
"access": event_match.group(1),
|
||||
"uid_state": event_match.group(2),
|
||||
"timestamp": convert_datetime_to_iso(
|
||||
datetime.strptime(event_match.group(3), "%Y-%m-%d %H:%M:%S.%f")
|
||||
),
|
||||
"relative_time": event_match.group(4),
|
||||
"duration": event_match.group(5),
|
||||
"attribution": attribution,
|
||||
}
|
||||
permission["entries"].append(entry)
|
||||
elif running_match:
|
||||
raw_start = running_match.group(1)
|
||||
timestamp = None
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", raw_start):
|
||||
timestamp = convert_datetime_to_iso(
|
||||
datetime.strptime(raw_start, "%Y-%m-%d %H:%M:%S.%f")
|
||||
)
|
||||
except ValueError:
|
||||
# Invalid date format
|
||||
pass
|
||||
permission["entries"].append(
|
||||
{
|
||||
"event": "running",
|
||||
"access": "Running",
|
||||
"uid_state": None,
|
||||
"timestamp": timestamp,
|
||||
"relative_time": raw_start
|
||||
if raw_start.startswith("+")
|
||||
else None,
|
||||
"duration": None,
|
||||
"attribution": attribution,
|
||||
}
|
||||
)
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
if package:
|
||||
self.results.append(package)
|
||||
finish_package()
|
||||
@@ -18,18 +18,18 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
action = record.get("action", "update")
|
||||
package_name = record["package_name"]
|
||||
vers = record["vers"]
|
||||
vers = record["version_code"]
|
||||
|
||||
if vers == "0":
|
||||
if vers == 0:
|
||||
data = f"Recorded uninstall of package {package_name} (vers 0)"
|
||||
elif action == "downgrade":
|
||||
prev_vers = record.get("previous_vers", "unknown")
|
||||
prev_vers = record.get("previous_version_code", "unknown")
|
||||
data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}"
|
||||
else:
|
||||
data = f"Recorded update of package {package_name} with vers {vers}"
|
||||
|
||||
return {
|
||||
"timestamp": record["from"],
|
||||
"timestamp": record["period_start"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "battery_daily",
|
||||
"data": data,
|
||||
@@ -48,6 +48,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
self.results = []
|
||||
daily = None
|
||||
daily_updates: list[dict[str, Any]] = []
|
||||
records: list[dict[str, Any]] = []
|
||||
@@ -59,7 +60,10 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
|
||||
timeframe = line[13:].strip()
|
||||
date_from, date_to = timeframe.strip(":").split(" to ", 1)
|
||||
daily = {"from": date_from[0:10], "to": date_to[0:10]}
|
||||
daily = {
|
||||
"period_start": self._format_daily_timestamp(date_from),
|
||||
"period_end": self._format_daily_timestamp(date_to),
|
||||
}
|
||||
continue
|
||||
|
||||
if not daily:
|
||||
@@ -70,21 +74,30 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
|
||||
line = line.strip().replace("Update ", "")
|
||||
package_name, vers = line.split(" ", 1)
|
||||
vers_nr = vers.split("=", 1)[1]
|
||||
vers_raw = vers.split("=", 1)[1]
|
||||
try:
|
||||
version_code: int | str = int(vers_raw)
|
||||
except ValueError:
|
||||
version_code = vers_raw
|
||||
|
||||
already_seen = False
|
||||
for update in daily_updates:
|
||||
if package_name == update["package_name"] and vers_nr == update["vers"]:
|
||||
if (
|
||||
package_name == update["package_name"]
|
||||
and version_code == update["version_code"]
|
||||
):
|
||||
update["occurrences"] += 1
|
||||
already_seen = True
|
||||
break
|
||||
|
||||
if not already_seen:
|
||||
update_record: dict[str, Any] = {
|
||||
"action": "update",
|
||||
"from": daily["from"],
|
||||
"to": daily["to"],
|
||||
"period_start": daily["period_start"],
|
||||
"period_end": daily["period_end"],
|
||||
"package_name": package_name,
|
||||
"vers": vers_nr,
|
||||
"version_code": version_code,
|
||||
"occurrences": 1,
|
||||
}
|
||||
|
||||
daily_updates.append(update_record)
|
||||
@@ -95,26 +108,31 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
self._detect_uninstalls_and_downgrades(records)
|
||||
self.results.extend(records)
|
||||
|
||||
def _detect_uninstalls_and_downgrades(
|
||||
self, records: list[dict[str, Any]]
|
||||
) -> None:
|
||||
@staticmethod
|
||||
def _format_daily_timestamp(value: str) -> str:
|
||||
if len(value) >= 19 and value[10] == "-":
|
||||
return f"{value[:10]} {value[11:].replace('-', ':')}"
|
||||
return value
|
||||
|
||||
def _detect_uninstalls_and_downgrades(self, records: list[dict[str, Any]]) -> None:
|
||||
package_versions: dict[str, int] = {}
|
||||
|
||||
for record in sorted(
|
||||
records,
|
||||
key=lambda record: (
|
||||
record["from"],
|
||||
record["to"],
|
||||
record["period_start"],
|
||||
record["period_end"],
|
||||
record["package_name"],
|
||||
),
|
||||
):
|
||||
package_name = record["package_name"]
|
||||
vers_nr = record["vers"]
|
||||
vers_nr = record["version_code"]
|
||||
|
||||
if vers_nr == "0":
|
||||
if vers_nr == 0:
|
||||
record["action"] = "uninstall"
|
||||
self.alertstore.medium(
|
||||
f"Detected uninstall of package {package_name} (vers 0)",
|
||||
record["from"],
|
||||
record["period_start"],
|
||||
record,
|
||||
)
|
||||
package_versions.pop(package_name, None)
|
||||
@@ -128,11 +146,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
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)
|
||||
record["previous_version_code"] = previous_vers
|
||||
self.alertstore.medium(
|
||||
f"Detected downgrade of package {package_name} "
|
||||
f"from vers {previous_vers} to vers {current_vers}",
|
||||
record["from"],
|
||||
record["period_start"],
|
||||
record,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,96 +1,177 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# 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 datetime
|
||||
import re
|
||||
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
class DumpsysBatteryHistoryArtifact(AndroidArtifact):
|
||||
"""
|
||||
Parser for dumpsys dattery history events.
|
||||
"""
|
||||
"""Parser for package-related events in dumpsys batterystats history."""
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_app_id(result["package_name"])
|
||||
package_name = result.get("package_name")
|
||||
if not package_name:
|
||||
continue
|
||||
ioc_match = self.indicators.check_app_id(package_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def _parse_wall_time(value: str) -> datetime.datetime | None:
|
||||
if re.fullmatch(r"\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", value):
|
||||
value = f"1900-{value}"
|
||||
for date_format in (
|
||||
"%Y-%m-%d-%H-%M-%S-%f",
|
||||
"%Y-%m-%d-%H-%M-%S",
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
):
|
||||
try:
|
||||
return datetime.datetime.strptime(value, date_format)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _elapsed_seconds(value: str) -> float | None:
|
||||
if not value.startswith("+"):
|
||||
return None
|
||||
units = {"d": 86400, "h": 3600, "m": 60, "s": 1, "ms": 0.001}
|
||||
total = 0.0
|
||||
for number, unit in re.findall(r"(\d+)(ms|d|h|m|s)", value):
|
||||
total += int(number) * units[unit]
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _package_from_name(name: str) -> str | None:
|
||||
clean = name.removeprefix("*walarm*:").removeprefix("*alarm*:")
|
||||
slash_parts = clean.split("/")
|
||||
if len(slash_parts) > 1:
|
||||
first = slash_parts[0].lstrip("@")
|
||||
if first.startswith(("com.", "org.", "net.")):
|
||||
return first
|
||||
for part in reversed(slash_parts[1:]):
|
||||
candidate = part.lstrip("@").split(":", 1)[0]
|
||||
if candidate.startswith(("com.", "org.", "net.")):
|
||||
return candidate
|
||||
parts = clean.split(".")
|
||||
package_parts = []
|
||||
for part in parts:
|
||||
if part and part[0].islower():
|
||||
package_parts.append(part)
|
||||
else:
|
||||
break
|
||||
return ".".join(package_parts) if len(package_parts) >= 2 else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_service(service: str) -> str:
|
||||
# WorkManager decorates jobs with one or more scheduler prefixes.
|
||||
if "@" in service:
|
||||
candidates = [part for part in service.split("@") if "/" in part]
|
||||
if candidates:
|
||||
return candidates[-1]
|
||||
return service
|
||||
|
||||
def parse(self, data: str) -> None:
|
||||
self.results: list[dict[str, str | None]] = []
|
||||
anchor_time: datetime.datetime | None = None
|
||||
anchor_elapsed = 0.0
|
||||
has_history_heading = any(
|
||||
line.startswith("Battery History") for line in data.splitlines()
|
||||
)
|
||||
in_history = not has_history_heading
|
||||
|
||||
for line in data.splitlines():
|
||||
if line.startswith("Battery History "):
|
||||
stripped = line.strip()
|
||||
if line.startswith("Battery History"):
|
||||
if in_history:
|
||||
break
|
||||
in_history = True
|
||||
continue
|
||||
|
||||
if line.strip() == "":
|
||||
if not in_history:
|
||||
continue
|
||||
if has_history_heading and not stripped:
|
||||
break
|
||||
|
||||
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"
|
||||
payload = line.split("+job=", 1)[1]
|
||||
uid, separator, service = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
service = service.strip().strip('"')
|
||||
package_name = service.split("/")[0]
|
||||
elif line.find("-job") > 0:
|
||||
event = "end_job"
|
||||
payload = line.split("-job=", 1)[1]
|
||||
uid, separator, service = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
service = service.strip().strip('"')
|
||||
package_name = service.split("/")[0]
|
||||
elif line.find("+running +wake_lock=") > 0:
|
||||
payload = line.split("+running +wake_lock=", 1)[1]
|
||||
uid, separator, _ = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
event = "wake"
|
||||
service = (
|
||||
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
|
||||
)
|
||||
if service == "" or "/" not in service:
|
||||
continue
|
||||
|
||||
package_name = service.split("/")[0]
|
||||
elif (line.find("+top=") > 0) or (line.find("-top") > 0):
|
||||
if line.find("+top=") > 0:
|
||||
event = "start_top"
|
||||
top_pos = line.find("+top=")
|
||||
else:
|
||||
event = "end_top"
|
||||
top_pos = line.find("-top=")
|
||||
colon_pos = top_pos + line[top_pos:].find(":")
|
||||
uid = line[top_pos + 5 : colon_pos]
|
||||
service = ""
|
||||
package_name = line[colon_pos + 1 :].strip('"')
|
||||
else:
|
||||
reset_match = re.search(r"(?:RESET:)?TIME:\s*(\S+)", stripped)
|
||||
if reset_match:
|
||||
parsed_time = self._parse_wall_time(reset_match.group(1))
|
||||
if parsed_time is not None:
|
||||
elapsed_token = stripped.split()[0]
|
||||
anchor_elapsed = self._elapsed_seconds(elapsed_token) or 0.0
|
||||
anchor_time = parsed_time
|
||||
continue
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"time_elapsed": time_elapsed,
|
||||
"event": event,
|
||||
"uid": uid,
|
||||
"package_name": package_name,
|
||||
"service": service,
|
||||
}
|
||||
)
|
||||
fields = stripped.split()
|
||||
if not fields:
|
||||
continue
|
||||
if len(fields) > 1 and re.fullmatch(r"\d{2}-\d{2}", fields[0]):
|
||||
time_elapsed = " ".join(fields[:2])
|
||||
line_time = self._parse_wall_time(time_elapsed)
|
||||
elapsed = None
|
||||
else:
|
||||
time_elapsed = fields[0]
|
||||
elapsed = self._elapsed_seconds(time_elapsed)
|
||||
line_time = None
|
||||
|
||||
timestamp = line_time
|
||||
if timestamp is None and anchor_time is not None and elapsed is not None:
|
||||
timestamp = anchor_time + datetime.timedelta(
|
||||
seconds=elapsed - anchor_elapsed
|
||||
)
|
||||
|
||||
def add(
|
||||
event: str, uid: str, service: str, package_name: str | None
|
||||
) -> None:
|
||||
self.results.append(
|
||||
{
|
||||
"time_elapsed": time_elapsed,
|
||||
"timestamp": convert_datetime_to_iso(timestamp)
|
||||
if timestamp
|
||||
else None,
|
||||
"event": event,
|
||||
"uid": uid,
|
||||
"package_name": package_name,
|
||||
"service": service,
|
||||
}
|
||||
)
|
||||
|
||||
for sign, uid, raw_service in re.findall(
|
||||
r"([+-])job=([^:\s]+):\"([^\"]+)\"", line
|
||||
):
|
||||
service = self._normalize_service(raw_service)
|
||||
add(
|
||||
"start_job" if sign == "+" else "end_job",
|
||||
uid,
|
||||
service,
|
||||
self._package_from_name(service) or service.split("/", 1)[0],
|
||||
)
|
||||
|
||||
for sign, uid, package_name in re.findall(
|
||||
r"([+-])top=([^:\s]+):\"([^\"]+)\"", line
|
||||
):
|
||||
add(
|
||||
"start_top" if sign == "+" else "end_top",
|
||||
uid,
|
||||
"",
|
||||
package_name,
|
||||
)
|
||||
|
||||
wake_match = re.search(r"\+wake_lock=([^:\s]+):\"([^\"]+)\"", line)
|
||||
if wake_match:
|
||||
wake_name = wake_match.group(2)
|
||||
add(
|
||||
"wake",
|
||||
wake_match.group(1),
|
||||
wake_name,
|
||||
self._package_from_name(wake_name),
|
||||
)
|
||||
@@ -29,16 +29,27 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
rxp = re.compile(
|
||||
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
|
||||
r"^\s*\d+:\s*\[((?:\d{4}-)?\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]\s*"
|
||||
r"(?:\[Pid:\((\d+)\)\])?\s*([\w-]+) took (\d+)ms - ([^,]+),"
|
||||
r"\s*sql=\"(.*)\"(?:, path=(.*))?$"
|
||||
)
|
||||
|
||||
pool = None
|
||||
pool: str | None = None
|
||||
connection_number: int | None = None
|
||||
is_primary: bool | None = None
|
||||
in_operations = False
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Connection pool for "):
|
||||
pool = line.replace("Connection pool for ", "").rstrip(":")
|
||||
in_operations = False
|
||||
|
||||
connection_match = re.match(r"\s+Connection #(\d+):", line)
|
||||
if connection_match:
|
||||
connection_number = int(connection_match.group(1))
|
||||
is_primary = None
|
||||
|
||||
if line.strip().startswith("isPrimaryConnection:"):
|
||||
is_primary = line.strip().split(":", 1)[1].strip() == "true"
|
||||
|
||||
if not pool:
|
||||
continue
|
||||
@@ -52,7 +63,6 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
|
||||
if not line.startswith(" "):
|
||||
in_operations = False
|
||||
pool = None
|
||||
continue
|
||||
|
||||
match = rxp.match(line)
|
||||
@@ -60,11 +70,15 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
result = {
|
||||
"isodate": match.group(1),
|
||||
"timestamp": match.group(1),
|
||||
"pid": int(match.group(2)) if match.group(2) else None,
|
||||
"action": match.group(3),
|
||||
"sql": match.group(4),
|
||||
"path": pool,
|
||||
"duration_ms": int(match.group(4)),
|
||||
"status": match.group(5),
|
||||
"sql": match.group(6),
|
||||
"path": match.group(7) or pool,
|
||||
"pool_path": pool,
|
||||
"connection_number": connection_number,
|
||||
"is_primary": is_primary,
|
||||
}
|
||||
if match.group(2):
|
||||
result["pid"] = match.group(2)
|
||||
self.results.append(result)
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
from .package_resolvers import parse_resolver_table
|
||||
|
||||
|
||||
class DumpsysPackageActivitiesArtifact(AndroidArtifact):
|
||||
@@ -19,67 +20,11 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
|
||||
)
|
||||
continue
|
||||
|
||||
def parse(self, content: str):
|
||||
def parse(self, content: str) -> None:
|
||||
"""
|
||||
Parse the Dumpsys Package section for activities
|
||||
Adds results to self.results
|
||||
|
||||
:param content: content of the package section (string)
|
||||
"""
|
||||
self.results = []
|
||||
|
||||
in_activity_resolver_table = False
|
||||
in_non_data_actions = False
|
||||
intent = None
|
||||
for line in content.splitlines():
|
||||
if line.startswith("Activity Resolver Table:"):
|
||||
in_activity_resolver_table = True
|
||||
continue
|
||||
|
||||
if not in_activity_resolver_table:
|
||||
continue
|
||||
|
||||
if line.startswith(" Non-Data Actions:"):
|
||||
in_non_data_actions = True
|
||||
continue
|
||||
|
||||
if not in_non_data_actions:
|
||||
continue
|
||||
|
||||
# If we hit an empty line, the Non-Data Actions section should be
|
||||
# finished.
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# We detect the action name.
|
||||
if (
|
||||
line.startswith(" " * 6)
|
||||
and not line.startswith(" " * 8)
|
||||
and ":" in line
|
||||
):
|
||||
intent = line.strip().replace(":", "")
|
||||
continue
|
||||
|
||||
# If we are not in an intent block yet, skip.
|
||||
if not intent:
|
||||
continue
|
||||
|
||||
# If we are in a block but the line does not start with 8 spaces
|
||||
# it means the block ended a new one started, so we reset and
|
||||
# continue.
|
||||
if not line.startswith(" " * 8):
|
||||
intent = None
|
||||
continue
|
||||
|
||||
# If we got this far, we are processing receivers for the
|
||||
# activities we are interested in.
|
||||
activity = line.strip().split(" ")[1]
|
||||
package_name = activity.split("/")[0]
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"intent": intent,
|
||||
"package_name": package_name,
|
||||
"activity": activity,
|
||||
}
|
||||
)
|
||||
self.results = parse_resolver_table(content, "Activity")
|
||||
@@ -1,10 +1,10 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any
|
||||
|
||||
from mvt.android.utils import ROOT_PACKAGES
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
@@ -12,196 +12,244 @@ from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
def _value(raw: str) -> Any:
|
||||
if raw == "null":
|
||||
return None
|
||||
if raw in ("true", "false"):
|
||||
return raw == "true"
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
def check_indicators(self) -> None:
|
||||
alerted_root_packages = set()
|
||||
for result in self.results:
|
||||
if result["package_name"] in ROOT_PACKAGES:
|
||||
if result["package_name"] in alerted_root_packages:
|
||||
continue
|
||||
alerted_root_packages.add(result["package_name"])
|
||||
package_name = result["package_name"]
|
||||
if (
|
||||
package_name in ROOT_PACKAGES
|
||||
and package_name not in alerted_root_packages
|
||||
):
|
||||
alerted_root_packages.add(package_name)
|
||||
self.alertstore.medium(
|
||||
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
|
||||
f'Found an installed package related to rooting/jailbreaking: "{package_name}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
continue
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(result.get("package_name", ""))
|
||||
ioc_match = self.indicators.check_app_id(package_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
timestamps = [
|
||||
{"event": "package_install", "timestamp": record["timestamp"]},
|
||||
("package_install", record.get("timestamp")),
|
||||
("package_last_update", record.get("last_update_time")),
|
||||
]
|
||||
timestamps.extend(
|
||||
("package_first_install", user.get("first_install_time"))
|
||||
for user in record.get("users", [])
|
||||
)
|
||||
return [
|
||||
{
|
||||
"event": "package_first_install",
|
||||
"timestamp": record["first_install_time"],
|
||||
},
|
||||
{"event": "package_last_update", "timestamp": record["last_update_time"]},
|
||||
"timestamp": timestamp,
|
||||
"module": self.__class__.__name__,
|
||||
"event": event,
|
||||
"data": f"Install or update of package {record['package_name']}",
|
||||
}
|
||||
for event, timestamp in timestamps
|
||||
if timestamp
|
||||
]
|
||||
|
||||
for timestamp in timestamps:
|
||||
records.append(
|
||||
{
|
||||
"timestamp": timestamp["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": timestamp["event"],
|
||||
"data": f"Install or update of package {record['package_name']}",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
@staticmethod
|
||||
def parse_dumpsys_package_for_details(output: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse one entry of a dumpsys package information
|
||||
"""
|
||||
details: Dict[str, Any] = {
|
||||
"uid": "",
|
||||
"version_name": "",
|
||||
"version_code": "",
|
||||
"timestamp": "",
|
||||
"first_install_time": "",
|
||||
"last_update_time": "",
|
||||
"permissions": list(),
|
||||
"requested_permissions": list(),
|
||||
def _permission(line: str, permission_type: str) -> dict:
|
||||
name, _, details = line.strip().partition(":")
|
||||
granted_match = re.search(r"granted=(true|false)", details)
|
||||
flags_match = re.search(r"flags=\[\s*([^]]*)\]", details)
|
||||
return {
|
||||
"name": name,
|
||||
"type": permission_type,
|
||||
"granted": granted_match.group(1) == "true" if granted_match else None,
|
||||
"flags": [
|
||||
flag.strip()
|
||||
for flag in (flags_match.group(1).split("|") if flags_match else [])
|
||||
if flag.strip()
|
||||
],
|
||||
}
|
||||
in_install_permissions = False
|
||||
in_runtime_permissions = False
|
||||
in_declared_permissions = False
|
||||
in_requested_permissions = True
|
||||
|
||||
@classmethod
|
||||
def parse_dumpsys_package_for_details(cls, output: str) -> dict[str, Any]:
|
||||
details: dict[str, Any] = {
|
||||
"app_id": None,
|
||||
"version_name": None,
|
||||
"version_code": None,
|
||||
"min_sdk": None,
|
||||
"target_sdk": None,
|
||||
"timestamp": None,
|
||||
"last_update_time": None,
|
||||
"installer": None,
|
||||
"system": False,
|
||||
"permissions": [],
|
||||
"requested_permissions": [],
|
||||
"users": [],
|
||||
}
|
||||
permission_section: str | None = None
|
||||
current_user: dict[str, Any] | None = None
|
||||
legacy_first_install: str | None = None
|
||||
|
||||
for line in output.splitlines():
|
||||
if in_install_permissions:
|
||||
if line.startswith(" " * 4) and not line.startswith(" " * 6):
|
||||
in_install_permissions = False
|
||||
else:
|
||||
lineinfo = line.strip().split(":")
|
||||
permission = lineinfo[0]
|
||||
granted = None
|
||||
if "granted=" in lineinfo[1]:
|
||||
granted = "granted=true" in lineinfo[1]
|
||||
stripped = line.strip()
|
||||
user_match = re.match(r"User (\d+):\s*(.*)", stripped)
|
||||
if user_match:
|
||||
current_user = {"user_id": int(user_match.group(1)), "permissions": []}
|
||||
for key, raw in re.findall(r"(\w+)=([^\s]+)", user_match.group(2)):
|
||||
clean_key = {
|
||||
"notLaunched": "not_launched",
|
||||
"installReason": "install_reason",
|
||||
"uninstallReason": "uninstall_reason",
|
||||
"dataDir": "data_dir",
|
||||
}.get(key, re.sub(r"(?<!^)(?=[A-Z])", "_", key).lower())
|
||||
current_user[clean_key] = _value(raw)
|
||||
details["users"].append(current_user)
|
||||
permission_section = None
|
||||
continue
|
||||
|
||||
details["permissions"].append(
|
||||
{"name": permission, "granted": granted, "type": "install"}
|
||||
)
|
||||
if in_runtime_permissions:
|
||||
if not line.startswith(" " * 8):
|
||||
in_runtime_permissions = False
|
||||
else:
|
||||
lineinfo = line.strip().split(":")
|
||||
permission = lineinfo[0]
|
||||
granted = None
|
||||
if "granted=" in lineinfo[1]:
|
||||
granted = "granted=true" in lineinfo[1]
|
||||
header = stripped.lower()
|
||||
if header in {
|
||||
"declared permissions:",
|
||||
"install permissions:",
|
||||
"requested permissions:",
|
||||
"runtime permissions:",
|
||||
}:
|
||||
permission_section = header.split()[0]
|
||||
continue
|
||||
|
||||
details["permissions"].append(
|
||||
{"name": permission, "granted": granted, "type": "runtime"}
|
||||
)
|
||||
if in_declared_permissions:
|
||||
if not line.startswith(" " * 6):
|
||||
in_declared_permissions = False
|
||||
else:
|
||||
permission = line.strip().split(":")[0]
|
||||
details["permissions"].append(
|
||||
{"name": permission, "type": "declared"}
|
||||
)
|
||||
if in_requested_permissions:
|
||||
if not line.startswith(" " * 6):
|
||||
in_requested_permissions = False
|
||||
else:
|
||||
details["requested_permissions"].append(line.strip())
|
||||
if line.strip().startswith("userId="):
|
||||
details["uid"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("versionName="):
|
||||
details["version_name"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("versionCode="):
|
||||
details["version_code"] = line.split("=", 1)[1].strip()
|
||||
elif line.strip().startswith("timeStamp="):
|
||||
details["timestamp"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("firstInstallTime="):
|
||||
details["first_install_time"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("lastUpdateTime="):
|
||||
details["last_update_time"] = line.split("=")[1].strip()
|
||||
elif line.strip() == "install permissions:":
|
||||
in_install_permissions = True
|
||||
elif line.strip() == "runtime permissions:":
|
||||
in_runtime_permissions = True
|
||||
elif line.strip() == "declared permissions:":
|
||||
in_declared_permissions = True
|
||||
elif line.strip() == "requested permissions:":
|
||||
in_requested_permissions = True
|
||||
|
||||
return details
|
||||
|
||||
def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse the dumpsys package service data
|
||||
"""
|
||||
pkg_rxp = re.compile(r" Package \[(.+?)\].*")
|
||||
|
||||
results = []
|
||||
package_name = None
|
||||
package = {}
|
||||
lines: list[str] = []
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" Package ["):
|
||||
if len(lines) > 0:
|
||||
details = self.parse_dumpsys_package_for_details("\n".join(lines))
|
||||
package.update(details)
|
||||
results.append(package)
|
||||
lines = []
|
||||
package = {}
|
||||
|
||||
matches = pkg_rxp.findall(line)
|
||||
if not matches:
|
||||
if current_user is not None:
|
||||
user_property = re.match(
|
||||
r"(installReason|uninstallReason|dataDir|firstInstallTime)=(.*)",
|
||||
stripped,
|
||||
)
|
||||
if user_property:
|
||||
key = {
|
||||
"installReason": "install_reason",
|
||||
"uninstallReason": "uninstall_reason",
|
||||
"dataDir": "data_dir",
|
||||
"firstInstallTime": "first_install_time",
|
||||
}[user_property.group(1)]
|
||||
current_user[key] = _value(user_property.group(2))
|
||||
continue
|
||||
|
||||
package_name = matches[0]
|
||||
package["package_name"] = package_name
|
||||
if permission_section == "requested" and line.startswith(" "):
|
||||
details["requested_permissions"].append(stripped)
|
||||
continue
|
||||
if permission_section in ("declared", "install") and line.startswith(
|
||||
" "
|
||||
):
|
||||
details["permissions"].append(
|
||||
cls._permission(stripped, permission_section)
|
||||
)
|
||||
continue
|
||||
if (
|
||||
permission_section == "runtime"
|
||||
and line.startswith(" ")
|
||||
and current_user is not None
|
||||
):
|
||||
current_user["permissions"].append(cls._permission(stripped, "runtime"))
|
||||
continue
|
||||
|
||||
if not package_name:
|
||||
simple_match = re.match(
|
||||
r"(appId|userId|versionName|timeStamp|lastUpdateTime|installerPackageName)=(.*)",
|
||||
stripped,
|
||||
)
|
||||
if simple_match:
|
||||
key = {
|
||||
"appId": "app_id",
|
||||
"userId": "app_id",
|
||||
"versionName": "version_name",
|
||||
"timeStamp": "timestamp",
|
||||
"lastUpdateTime": "last_update_time",
|
||||
"installerPackageName": "installer",
|
||||
}[simple_match.group(1)]
|
||||
raw_value = simple_match.group(2)
|
||||
details[key] = (
|
||||
_value(raw_value)
|
||||
if key == "app_id" or raw_value == "null"
|
||||
else raw_value
|
||||
)
|
||||
continue
|
||||
if stripped.startswith("pkgFlags="):
|
||||
details["system"] = "SYSTEM" in stripped.split("=", 1)[1].split()
|
||||
continue
|
||||
version_match = re.match(
|
||||
r"versionCode=([^\s]+)(?:\s+minSdk=([^\s]+))?(?:\s+targetSdk=([^\s]+))?",
|
||||
stripped,
|
||||
)
|
||||
if version_match:
|
||||
details["version_code"] = _value(version_match.group(1))
|
||||
details["min_sdk"] = (
|
||||
_value(version_match.group(2)) if version_match.group(2) else None
|
||||
)
|
||||
details["target_sdk"] = (
|
||||
_value(version_match.group(3)) if version_match.group(3) else None
|
||||
)
|
||||
elif stripped.startswith("firstInstallTime="):
|
||||
legacy_first_install = stripped.split("=", 1)[1]
|
||||
|
||||
lines.append(line)
|
||||
if legacy_first_install:
|
||||
user_zero = next(
|
||||
(user for user in details["users"] if user["user_id"] == 0), None
|
||||
)
|
||||
if user_zero is None:
|
||||
user_zero = {"user_id": 0, "permissions": []}
|
||||
details["users"].append(user_zero)
|
||||
user_zero.setdefault("first_install_time", legacy_first_install)
|
||||
return details
|
||||
|
||||
if len(lines) > 0:
|
||||
details = self.parse_dumpsys_package_for_details("\n".join(lines))
|
||||
package.update(details)
|
||||
results.append(package)
|
||||
def parse(self, content: str) -> None:
|
||||
self.results: list[dict[str, Any]] = []
|
||||
category: str | None = None
|
||||
package: dict[str, Any] | None = None
|
||||
block: list[str] = []
|
||||
|
||||
return results
|
||||
def finish() -> None:
|
||||
nonlocal package, block
|
||||
if package is not None:
|
||||
package.update(self.parse_dumpsys_package_for_details("\n".join(block)))
|
||||
self.results.append(package)
|
||||
package = None
|
||||
block = []
|
||||
|
||||
def parse(self, content: str):
|
||||
"""
|
||||
Parse the Dumpsys Package section for activities
|
||||
Adds results to self.results
|
||||
|
||||
:param content: content of the package section (string)
|
||||
"""
|
||||
self.results = []
|
||||
package = []
|
||||
|
||||
in_package_list = False
|
||||
for line in content.splitlines():
|
||||
if line.startswith("Packages:"):
|
||||
in_package_list = True
|
||||
if line == "Packages:":
|
||||
finish()
|
||||
category = "active"
|
||||
continue
|
||||
|
||||
if not in_package_list:
|
||||
if line == "Hidden system packages:":
|
||||
finish()
|
||||
category = "hidden_system"
|
||||
continue
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
package.append(line)
|
||||
|
||||
self.results = self.parse_dumpsys_packages("\n".join(package))
|
||||
package_match = re.match(r"^ Package \[([^]]+)\]", line)
|
||||
if package_match and category:
|
||||
finish()
|
||||
package = {
|
||||
"package_name": package_match.group(1),
|
||||
"package_type": category,
|
||||
}
|
||||
continue
|
||||
if (
|
||||
category
|
||||
and line
|
||||
and not line.startswith(" ")
|
||||
and not line.endswith(" overlay paths:")
|
||||
):
|
||||
finish()
|
||||
category = None
|
||||
continue
|
||||
if package is not None:
|
||||
block.append(line)
|
||||
finish()
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
@@ -25,19 +27,37 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, data: str) -> None:
|
||||
for line in data.splitlines():
|
||||
if not line.startswith("ChangeId(168419799; name=DOWNSCALED;"):
|
||||
match = re.match(r"ChangeId\((\d+);\s*(.*)\)$", line.strip())
|
||||
if not match or "rawOverrides={" not in line:
|
||||
continue
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# Look for rawOverrides field
|
||||
if "rawOverrides={" in line:
|
||||
# Extract the content inside the braces for rawOverrides
|
||||
overrides_field = line.split("rawOverrides={", 1)[1].split("};", 1)[0]
|
||||
|
||||
for entry in overrides_field.split(", "):
|
||||
# Extract app name
|
||||
uninstall_app = entry.split("=")[0].strip()
|
||||
|
||||
self.results.append({"package_name": uninstall_app})
|
||||
body = match.group(2)
|
||||
name_match = re.search(r"(?:^|;\s*)name=([^;]+)", body)
|
||||
state = (
|
||||
"enabled"
|
||||
if re.search(r"(?:^|;\s*)enabled(?:;|$)", body)
|
||||
else "disabled"
|
||||
)
|
||||
overridable = bool(re.search(r"(?:^|;\s*)overridable(?:;|$)", body))
|
||||
overrides_field = body.split("rawOverrides={", 1)[1].split("}", 1)[0]
|
||||
for entry in overrides_field.split(","):
|
||||
package_name, separator, raw_value = entry.strip().partition("=")
|
||||
if not separator:
|
||||
continue
|
||||
value: bool | int | str
|
||||
if raw_value in ("true", "false"):
|
||||
value = raw_value == "true"
|
||||
else:
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except ValueError:
|
||||
value = raw_value
|
||||
self.results.append(
|
||||
{
|
||||
"change_id": int(match.group(1)),
|
||||
"change_name": name_match.group(1) if name_match else None,
|
||||
"change_state": state,
|
||||
"overridable": overridable,
|
||||
"package_name": package_name,
|
||||
"override_value": value,
|
||||
}
|
||||
)
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
from .package_resolvers import parse_resolver_table
|
||||
|
||||
INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS"
|
||||
INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED"
|
||||
@@ -18,115 +19,45 @@ class DumpsysReceiversArtifact(AndroidArtifact):
|
||||
"""
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for intent, receivers in self.results.items():
|
||||
for receiver in receivers:
|
||||
if intent == INTENT_NEW_OUTGOING_SMS:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept outgoing SMS messages: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming SMS messages: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_DATA_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming data SMS message: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_PHONE_STATE:
|
||||
self.log.info(
|
||||
"Found a receiver monitoring "
|
||||
'telephony state/incoming calls: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_NEW_OUTGOING_CALL:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring outgoing calls: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
for receiver in self.results:
|
||||
intent = receiver["key"]
|
||||
if intent == INTENT_NEW_OUTGOING_SMS:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept outgoing SMS messages: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming SMS messages: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_DATA_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming data SMS message: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_PHONE_STATE:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring telephony state/incoming calls: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_NEW_OUTGOING_CALL:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring outgoing calls: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
if not self.indicators:
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(receiver["package_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
{intent: receiver},
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
continue
|
||||
ioc_match = self.indicators.check_app_id(receiver["package_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
receiver,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
self.results: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
in_receiver_resolver_table = False
|
||||
in_non_data_actions = False
|
||||
intent = None
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Receiver Resolver Table:"):
|
||||
in_receiver_resolver_table = True
|
||||
continue
|
||||
|
||||
if not in_receiver_resolver_table:
|
||||
continue
|
||||
|
||||
if line.startswith(" Non-Data Actions:"):
|
||||
in_non_data_actions = True
|
||||
continue
|
||||
|
||||
if not in_non_data_actions:
|
||||
continue
|
||||
|
||||
# If we hit an empty line, the Non-Data Actions section should be
|
||||
# finished.
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# We detect the action name.
|
||||
if (
|
||||
line.startswith(" " * 6)
|
||||
and not line.startswith(" " * 8)
|
||||
and ":" in line
|
||||
):
|
||||
intent = line.strip().replace(":", "")
|
||||
self.results[intent] = []
|
||||
continue
|
||||
|
||||
parts = line.strip().split(" ")
|
||||
if len(parts) < 2:
|
||||
# A single-token line here is not a receiver. Real dumpstate
|
||||
# output can print an action header mis-indented (observed with
|
||||
# 15 leading spaces instead of 6), which used to raise
|
||||
# IndexError and abort the whole module. Treat a trailing-colon
|
||||
# token as the next action, skip anything else.
|
||||
if parts[0].endswith(":"):
|
||||
intent = parts[0][:-1]
|
||||
self.results.setdefault(intent, [])
|
||||
continue
|
||||
|
||||
# If we are not in an intent block yet, skip.
|
||||
if not intent:
|
||||
continue
|
||||
|
||||
# If we are in a block but the line does not start with 8 spaces
|
||||
# it means the block ended a new one started, so we reset and
|
||||
# continue.
|
||||
if not line.startswith(" " * 8):
|
||||
intent = None
|
||||
continue
|
||||
|
||||
# If we got this far, we are processing receivers for the
|
||||
# activities we are interested in.
|
||||
receiver = parts[1]
|
||||
package_name = receiver.split("/")[0]
|
||||
|
||||
self.results[intent].append(
|
||||
{
|
||||
"package_name": package_name,
|
||||
"receiver": receiver,
|
||||
}
|
||||
)
|
||||
self.results = parse_resolver_table(output, "Receiver")
|
||||
@@ -28,19 +28,16 @@ INTERESTING_PROPERTIES = [
|
||||
class GetProp(AndroidArtifact):
|
||||
def parse(self, entry: str) -> None:
|
||||
self.results: List[Dict[str, str]] = []
|
||||
rxp = re.compile(r"\[(.+?)\]: \[(.+?)\]")
|
||||
# A property value may span several lines: persist.sys.boot.reason.history
|
||||
# prints one boot per line. Matching the whole section instead of line by
|
||||
# line lets a value run to the first closing bracket that ends a line.
|
||||
rxp = re.compile(
|
||||
r"^[ \t]*\[([^]]+)\]: \[(.*?)\][ \t\r]*$",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
|
||||
for line in entry.splitlines():
|
||||
line = line.strip()
|
||||
if line == "":
|
||||
continue
|
||||
|
||||
matches = re.findall(rxp, line)
|
||||
if not matches or len(matches[0]) != 2:
|
||||
continue
|
||||
|
||||
prop_entry = {"name": matches[0][0], "value": matches[0][1]}
|
||||
self.results.append(prop_entry)
|
||||
for name, value in rxp.findall(entry):
|
||||
self.results.append({"name": name, "value": value})
|
||||
|
||||
def get_device_timezone(self) -> str | None:
|
||||
"""
|
||||
|
||||
@@ -117,6 +117,51 @@ class Mounts(AndroidArtifact):
|
||||
# Skip lines that don't match expected format
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def parse_mountinfo(entry: str, process_id: int) -> list[dict[str, Any]]:
|
||||
"""Parse Linux /proc/PID/mountinfo records."""
|
||||
results = []
|
||||
for line in entry.splitlines():
|
||||
fields = line.split()
|
||||
if "-" not in fields:
|
||||
continue
|
||||
separator = fields.index("-")
|
||||
if separator < 6 or len(fields) < separator + 4:
|
||||
continue
|
||||
try:
|
||||
mount_id = int(fields[0])
|
||||
parent_id = int(fields[1])
|
||||
except ValueError:
|
||||
continue
|
||||
mount_options = fields[5].split(",")
|
||||
super_options = fields[separator + 3].split(",")
|
||||
options = list(dict.fromkeys(mount_options + super_options))
|
||||
mount_point = fields[4].replace("\\040", " ")
|
||||
device = fields[separator + 2].replace("\\040", " ")
|
||||
filesystem_type = fields[separator + 1]
|
||||
is_system = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
|
||||
mount_point.startswith(f"{prefix}/")
|
||||
for prefix in SUSPICIOUS_MOUNT_POINTS
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"mount_id": mount_id,
|
||||
"parent_id": parent_id,
|
||||
"major_minor": fields[2],
|
||||
"root": fields[3].replace("\\040", " "),
|
||||
"mount_point": mount_point,
|
||||
"device": device,
|
||||
"filesystem_type": filesystem_type,
|
||||
"mount_options": ",".join(options),
|
||||
"options_list": options,
|
||||
"optional_fields": fields[6:separator],
|
||||
"is_system_partition": is_system,
|
||||
"is_read_write": "rw" in options,
|
||||
"process_ids": [process_id],
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
"""
|
||||
Check for suspicious mount configurations that may indicate root access
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
|
||||
|
||||
RESOLVER_TYPES = {
|
||||
"Full MIME Types": "full_mime_type",
|
||||
"Base MIME Types": "base_mime_type",
|
||||
"Wild MIME Types": "wild_mime_type",
|
||||
"Schemes": "scheme",
|
||||
"Non-Data Actions": "non_data_action",
|
||||
"MIME Typed Actions": "mime_typed_action",
|
||||
}
|
||||
|
||||
ENTRY_RE = re.compile(
|
||||
r"^\s{8,}[0-9a-fA-F]+\s+(?P<component>\S+)"
|
||||
r"(?:\s+\((?P<filters>\d+)\s+filters?\))?\s*$"
|
||||
)
|
||||
|
||||
|
||||
def parse_resolver_table(content: str, table_name: str) -> list[dict]:
|
||||
"""Parse every resolver category from a dumpsys package table."""
|
||||
results: list[dict] = []
|
||||
in_table = False
|
||||
resolver_type: str | None = None
|
||||
key: str | None = None
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.startswith(f"{table_name} Resolver Table:"):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
|
||||
if line and not line.startswith(" "):
|
||||
break
|
||||
|
||||
section_match = re.match(r"^ {2}([^ ].*):\s*$", line)
|
||||
if section_match:
|
||||
resolver_type = RESOLVER_TYPES.get(section_match.group(1))
|
||||
key = None
|
||||
continue
|
||||
if resolver_type is None:
|
||||
continue
|
||||
|
||||
key_match = re.match(r"^ {6,}([^ ].*):\s*$", line)
|
||||
if key_match:
|
||||
key = key_match.group(1)
|
||||
continue
|
||||
if key is None:
|
||||
continue
|
||||
|
||||
entry_match = ENTRY_RE.match(line)
|
||||
if not entry_match:
|
||||
continue
|
||||
component = entry_match.group("component")
|
||||
results.append(
|
||||
{
|
||||
"resolver_type": resolver_type,
|
||||
"key": key,
|
||||
"package_name": component.split("/", 1)[0],
|
||||
"component": component,
|
||||
"filter_count": int(entry_match.group("filters") or 1),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -1,72 +1,91 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# 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/
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
FIELD_NAMES = {
|
||||
"LABEL": "label",
|
||||
"USER": "user",
|
||||
"PID": "pid",
|
||||
"TID": "tid",
|
||||
"PPID": "ppid",
|
||||
"VSZ": "virtual_memory_size",
|
||||
"RSS": "resident_set_size",
|
||||
"WCHAN": "wchan",
|
||||
"ADDR": "address",
|
||||
"S": "state",
|
||||
"PRI": "priority",
|
||||
"NI": "nice",
|
||||
"RTPRIO": "realtime_priority",
|
||||
"SCH": "scheduler",
|
||||
"PCY": "policy",
|
||||
"TIME": "cpu_time",
|
||||
"CMD": "command",
|
||||
"NAME": "command",
|
||||
}
|
||||
INTEGER_FIELDS = {
|
||||
"pid",
|
||||
"tid",
|
||||
"ppid",
|
||||
"virtual_memory_size",
|
||||
"resident_set_size",
|
||||
"priority",
|
||||
"nice",
|
||||
}
|
||||
|
||||
|
||||
class Processes(AndroidArtifact):
|
||||
def parse(self, entry: str) -> None:
|
||||
for line in entry.splitlines()[1:]:
|
||||
proc = line.split()
|
||||
self.results = []
|
||||
lines = [line for line in entry.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return
|
||||
headers = lines[0].split()
|
||||
if not all(header in FIELD_NAMES for header in headers):
|
||||
return
|
||||
|
||||
# Skip empty lines
|
||||
if len(proc) == 0:
|
||||
for line in lines[1:]:
|
||||
values = line.split(None, len(headers) - 1)
|
||||
if len(values) != len(headers):
|
||||
continue
|
||||
|
||||
# Sometimes WCHAN is empty.
|
||||
if len(proc) == 8:
|
||||
proc = proc[:5] + [""] + proc[5:]
|
||||
|
||||
# Sometimes there is the security label.
|
||||
if proc[0].startswith("u:r"):
|
||||
label = proc[0]
|
||||
proc = proc[1:]
|
||||
else:
|
||||
label = ""
|
||||
|
||||
# Sometimes there is no WCHAN.
|
||||
if len(proc) < 9:
|
||||
proc = proc[:5] + [""] + proc[5:]
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"user": proc[0],
|
||||
"pid": int(proc[1]),
|
||||
"ppid": int(proc[2]),
|
||||
"virtual_memory_size": int(proc[3]),
|
||||
"resident_set_size": int(proc[4]),
|
||||
"wchan": proc[5],
|
||||
"aprocress": proc[6],
|
||||
"stat": proc[7],
|
||||
"proc_name": proc[8].strip("[]"),
|
||||
"label": label,
|
||||
}
|
||||
)
|
||||
result = {}
|
||||
valid = True
|
||||
for header, raw in zip(headers, values):
|
||||
key = FIELD_NAMES[header]
|
||||
value: str | int = raw.strip("[]") if key == "command" else raw
|
||||
if key in INTEGER_FIELDS:
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
valid = False
|
||||
break
|
||||
result[key] = value
|
||||
if valid:
|
||||
self.results.append(result)
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
proc_name = result.get("proc_name", "")
|
||||
if not proc_name:
|
||||
command = result.get("command", "")
|
||||
if not isinstance(command, str):
|
||||
continue
|
||||
|
||||
# Skipping this process because of false positives.
|
||||
if result["proc_name"] == "gatekeeperd":
|
||||
process_name = command.rsplit("/", 1)[-1]
|
||||
if not process_name or process_name == "gatekeeperd":
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(proc_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_process(proc_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
for checker in (
|
||||
self.indicators.check_app_id,
|
||||
self.indicators.check_process,
|
||||
):
|
||||
ioc_match = checker(process_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
break
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
ANDROID_DANGEROUS_SETTINGS = [
|
||||
@@ -60,6 +62,28 @@ ANDROID_DANGEROUS_SETTINGS = [
|
||||
|
||||
|
||||
class Settings(AndroidArtifact):
|
||||
def parse(self, content: str) -> None:
|
||||
self.results: dict[str, dict[str, str]] = {}
|
||||
namespace: str | None = None
|
||||
for line in content.splitlines():
|
||||
heading = re.match(
|
||||
r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$",
|
||||
line.strip(),
|
||||
)
|
||||
if heading:
|
||||
namespace = f"{heading.group(1).lower()}:user_{heading.group(2)}"
|
||||
self.results[namespace] = {}
|
||||
continue
|
||||
if namespace is None or not line.startswith("_id:"):
|
||||
continue
|
||||
setting = re.match(
|
||||
r"^_id:\S+\s+name:(.*?)\s+pkg:.*?\s+value:(.*?)"
|
||||
r"(?:\s+default:.*\s+defaultSystemSet:(?:true|false))?$",
|
||||
line,
|
||||
)
|
||||
if setting:
|
||||
self.results[namespace][setting.group(1)] = setting.group(2)
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for namespace, settings in self.results.items():
|
||||
for key, value in settings.items():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -128,6 +131,13 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
|
||||
) -> None:
|
||||
"""Parse Android tombstone crash files from a protobuf object."""
|
||||
self.results.append(self.parse_protobuf_record(file_name, file_timestamp, data))
|
||||
|
||||
def parse_protobuf_record(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
|
||||
) -> dict:
|
||||
if not data:
|
||||
raise ValueError("empty protobuf tombstone")
|
||||
tombstone_pb = Tombstone().parse(data)
|
||||
tombstone_dict = tombstone_pb.to_dict(
|
||||
casing=betterproto2.Casing.SNAKE, include_default_values=True
|
||||
@@ -140,20 +150,31 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
tombstone_dict["file_name"] = file_name
|
||||
tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp)
|
||||
tombstone_dict["process_name"] = self._proccess_name_from_thread(tombstone_dict)
|
||||
if isinstance(tombstone_dict.get("selinux_label"), str):
|
||||
tombstone_dict["selinux_label"] = tombstone_dict["selinux_label"].rstrip(
|
||||
"\x00"
|
||||
)
|
||||
|
||||
# Confirm the tombstone is valid, and matches the output model
|
||||
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
|
||||
self.results.append(tombstone.model_dump())
|
||||
return tombstone.model_dump()
|
||||
|
||||
def parse(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
|
||||
) -> None:
|
||||
"""Parse text Android tombstone crash files."""
|
||||
self.results.append(self.parse_text_record(file_name, file_timestamp, content))
|
||||
|
||||
def parse_text_record(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
|
||||
) -> dict:
|
||||
if not content:
|
||||
raise ValueError("empty plaintext tombstone")
|
||||
tombstone_dict = {
|
||||
"file_name": file_name,
|
||||
"file_timestamp": convert_datetime_to_iso(file_timestamp),
|
||||
}
|
||||
lines = content.decode("utf-8").splitlines()
|
||||
lines = content.decode("utf-8", errors="replace").splitlines()
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
if not line.strip() or TOMBSTONE_DELIMITER in line:
|
||||
continue
|
||||
@@ -168,7 +189,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
|
||||
# Validate the tombstone and add it to the results
|
||||
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
|
||||
self.results.append(tombstone.model_dump())
|
||||
return tombstone.model_dump()
|
||||
|
||||
def _parse_tombstone_line(
|
||||
self, line: str, key: str, destination_key: str, tombstone: dict
|
||||
@@ -188,11 +209,18 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
def _load_key_value_line(
|
||||
self, line: str, key: str, destination_key: str, tombstone: dict
|
||||
) -> bool:
|
||||
line_key, value = line.split(":", 1)
|
||||
if line_key != key:
|
||||
raise ValueError(f"Expected key {key}, got {line_key}")
|
||||
# The caller matched the key as a bare prefix, so a longer word starting
|
||||
# with it arrives here: `Caused by: …` inside an abort message reaches
|
||||
# the `Cause` key. That is a different line, not a broken file — say so
|
||||
# by declining it, and let the remaining keys have their turn. Raising
|
||||
# here discarded the whole tombstone, crash and stack trace included.
|
||||
line_key, separator, value = line.partition(":")
|
||||
if not separator or line_key != key:
|
||||
return False
|
||||
|
||||
value_clean = value.strip().strip("'")
|
||||
value_clean = value.strip()
|
||||
if len(value_clean) >= 2 and value_clean[0] == value_clean[-1] == "'":
|
||||
value_clean = value_clean[1:-1]
|
||||
if destination_key == "uid":
|
||||
tombstone[destination_key] = int(value_clean)
|
||||
elif destination_key == "process_uptime":
|
||||
@@ -266,9 +294,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
@staticmethod
|
||||
def _parse_timestamp_string(timestamp: str) -> str:
|
||||
timestamp_parsed = parser.parse(timestamp)
|
||||
# Preserve the source wall-clock time while returning the project-wide ISO format.
|
||||
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
|
||||
return convert_datetime_to_iso(local_timestamp)
|
||||
return convert_datetime_to_iso(timestamp_parsed)
|
||||
|
||||
@staticmethod
|
||||
def _proccess_name_from_thread(tombstone_dict: dict) -> str:
|
||||
|
||||
+73
-71
@@ -8,12 +8,11 @@ from zipfile import BadZipFile
|
||||
|
||||
import click
|
||||
|
||||
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.cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
load_cli_commands_option,
|
||||
register_cli_plugins,
|
||||
)
|
||||
from mvt.common.help import (
|
||||
HELP_MSG_ANDROID_BACKUP_PASSWORD,
|
||||
@@ -25,7 +24,6 @@ from mvt.common.help import (
|
||||
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,
|
||||
@@ -37,23 +35,16 @@ from mvt.common.help import (
|
||||
HELP_MSG_OUTPUT,
|
||||
HELP_MSG_STIX2,
|
||||
HELP_MSG_VERBOSE,
|
||||
HELP_MSG_VERBOSE_COMMAND,
|
||||
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
|
||||
|
||||
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
||||
from .cmd_check_backup import CmdAndroidCheckBackup
|
||||
from .cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
||||
from .modules.intrusion_logs import INTRUSION_LOGS_MODULES
|
||||
from .modules.androidqf import ANDROIDQF_MODULES
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
from .modules.backup.helpers import cli_load_android_backup_password
|
||||
from .modules.bugreport import BUGREPORT_MODULES
|
||||
# The commands import what they run only when they are invoked. This module is
|
||||
# imported at every start of mvt-android, including by shell completion on
|
||||
# every keystroke, so importing it must do no more than build the command tree:
|
||||
# the forensic modules, the backup parsers and the update checks stay out of it.
|
||||
|
||||
init_logging()
|
||||
log = logging.getLogger("mvt")
|
||||
@@ -71,7 +62,14 @@ def _get_disable_flags(ctx):
|
||||
)
|
||||
|
||||
|
||||
def _get_verbose(ctx):
|
||||
"""Return whether --verbose was passed to the CLI itself."""
|
||||
return bool(ctx.obj and ctx.obj.get("verbose", False))
|
||||
|
||||
|
||||
def _load_custom_modules(load_module):
|
||||
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
|
||||
|
||||
try:
|
||||
return load_custom_modules(load_module)
|
||||
except CustomModuleLoadError as exc:
|
||||
@@ -82,6 +80,7 @@ def _load_custom_modules(load_module):
|
||||
# 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
|
||||
)
|
||||
@@ -90,60 +89,31 @@ def _load_custom_modules(load_module):
|
||||
is_flag=True,
|
||||
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.pass_context
|
||||
def cli(ctx, disable_update_check, disable_indicator_update_check):
|
||||
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["disable_version_check"] = disable_update_check
|
||||
ctx.obj["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,
|
||||
)
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
from mvt.common.logo import logo
|
||||
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: version
|
||||
# ==============================================================================
|
||||
@cli.command("version", help=HELP_MSG_VERSION)
|
||||
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
|
||||
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)
|
||||
# ==============================================================================
|
||||
@@ -180,7 +150,7 @@ def check_adb(ctx):
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_bugreport(
|
||||
@@ -193,7 +163,9 @@ def check_bugreport(
|
||||
verbose,
|
||||
bugreport_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
from .cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
# Always generate hashes as bug reports are small.
|
||||
cmd = CmdAndroidCheckBugreport(
|
||||
@@ -248,7 +220,7 @@ def check_bugreport(
|
||||
)
|
||||
@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)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_backup(
|
||||
@@ -262,7 +234,10 @@ def check_backup(
|
||||
verbose,
|
||||
backup_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
from .cmd_check_backup import CmdAndroidCheckBackup
|
||||
from .modules.backup.helpers import cli_load_android_backup_password
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
# Always generate hashes as backups are generally small.
|
||||
@@ -322,7 +297,7 @@ def check_backup(
|
||||
)
|
||||
@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)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_androidqf(
|
||||
@@ -340,7 +315,10 @@ def check_androidqf(
|
||||
verbose,
|
||||
androidqf_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
||||
from .modules.backup.helpers import cli_load_android_backup_password
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
cmd = CmdAndroidCheckAndroidQF(
|
||||
@@ -408,7 +386,7 @@ def check_androidqf(
|
||||
"time instead of UTC."
|
||||
),
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("LOGS_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_intrusion_logs(
|
||||
@@ -422,7 +400,9 @@ def check_intrusion_logs(
|
||||
verbose,
|
||||
logs_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
module_options = {}
|
||||
@@ -475,6 +455,10 @@ def check_intrusion_logs(
|
||||
@click.argument("FOLDER", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
from mvt.common.cmd_check_iocs import CmdCheckIOCS
|
||||
|
||||
from .command_modules import ANDROID_CHECK_IOCS_MODULES
|
||||
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdCheckIOCS(
|
||||
target_path=folder,
|
||||
@@ -485,9 +469,7 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
custom_modules=custom_modules,
|
||||
platform="android",
|
||||
)
|
||||
cmd.modules = (
|
||||
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
|
||||
)
|
||||
cmd.modules = ANDROID_CHECK_IOCS_MODULES
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
@@ -503,5 +485,25 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
# ==============================================================================
|
||||
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
|
||||
def download_indicators():
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
ioc_updates.update()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Entry point of the mvt-android console script
|
||||
# ==============================================================================
|
||||
def main() -> None:
|
||||
"""Register the external commands and run the mvt-android CLI.
|
||||
|
||||
External commands are registered here rather than when this module is
|
||||
imported, so that importing MVT never runs third-party code and a plugin
|
||||
importing from MVT cannot re-enter a module that is still initializing.
|
||||
"""
|
||||
register_cli_plugins(
|
||||
cli,
|
||||
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
cli()
|
||||
@@ -3,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
|
||||
@@ -94,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)
|
||||
@@ -220,6 +262,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
cmd.run()
|
||||
|
||||
self.timeline.extend(cmd.timeline)
|
||||
self.url_results.extend(cmd.url_results)
|
||||
self.alertstore.extend(cmd.alertstore.alerts)
|
||||
finally:
|
||||
if bugreport:
|
||||
@@ -249,14 +292,13 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
try:
|
||||
cmd.from_ab(backup)
|
||||
except InvalidAndroidBackup as exc:
|
||||
self.log.warning(
|
||||
"Skipping backup modules as backup.ab is malformed: %s", exc
|
||||
)
|
||||
self.log.warning("Skipping backup modules: %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
|
||||
|
||||
@@ -336,6 +378,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
cmd.run()
|
||||
|
||||
self.timeline.extend(cmd.timeline)
|
||||
self.url_results.extend(cmd.url_results)
|
||||
self.alertstore.extend(cmd.alertstore.alerts)
|
||||
return True
|
||||
|
||||
|
||||
@@ -87,11 +87,15 @@ class CmdAndroidCheckBackup(Command):
|
||||
if header["encryption"] != "none":
|
||||
password = prompt_or_load_android_backup_password(log, self.module_options)
|
||||
if not password:
|
||||
if self.sub_command:
|
||||
raise InvalidAndroidBackup("No backup password provided")
|
||||
log.critical("No backup password provided.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
tardata = parse_backup_file(ab_file_bytes, password=password)
|
||||
except InvalidBackupPassword:
|
||||
if self.sub_command:
|
||||
raise InvalidAndroidBackup("Invalid backup password")
|
||||
log.critical("Invalid backup password")
|
||||
sys.exit(1)
|
||||
except AndroidBackupParsingError as exc:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Module lists an mvt-android command composes from more than one family.
|
||||
|
||||
Commands whose modules are one family read that family directly. check-iocs
|
||||
re-checks stored results, so it has to know every module that could have
|
||||
written one, and both the CLI and any other code needing that answer share
|
||||
the list from here rather than each concatenating their own.
|
||||
"""
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.androidqf import ANDROIDQF_MODULES
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
from .modules.bugreport import BUGREPORT_MODULES
|
||||
from .modules.intrusion_logs import INTRUSION_LOGS_MODULES
|
||||
|
||||
ANDROID_CHECK_IOCS_MODULES: list[type[MVTModule]] = (
|
||||
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
|
||||
)
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .aqf_files import AQFFiles
|
||||
from .aqf_getprop import AQFGetProp
|
||||
from .aqf_log_timestamps import AQFLogTimestamps
|
||||
@@ -12,7 +14,7 @@ from .aqf_settings import AQFSettings
|
||||
from .mounts import Mounts
|
||||
from .root_binaries import RootBinaries
|
||||
|
||||
ANDROIDQF_MODULES = [
|
||||
ANDROIDQF_MODULES: list[type[MVTModule]] = [
|
||||
AQFPackages,
|
||||
AQFProcesses,
|
||||
AQFGetProp,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .sms import SMS
|
||||
|
||||
BACKUP_MODULES = [SMS]
|
||||
BACKUP_MODULES: list[type[MVTModule]] = [SMS]
|
||||
@@ -57,6 +57,11 @@ class SMS(BackupModule):
|
||||
ioc_match.message, "", message, 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:
|
||||
sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup"
|
||||
for file in self._get_files_by_pattern(sms_path):
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .dumpsys_accessibility import DumpsysAccessibility
|
||||
from .dumpsys_activities import DumpsysActivities
|
||||
from .dumpsys_appops import DumpsysAppops
|
||||
@@ -16,8 +18,11 @@ from .dumpsys_receivers import DumpsysReceivers
|
||||
from .dumpsys_adb_state import DumpsysADBState
|
||||
from .fs_timestamps import BugReportTimestamps
|
||||
from .tombstones import Tombstones
|
||||
from .mounts import Mounts
|
||||
from .processes import Processes
|
||||
from .settings import Settings
|
||||
|
||||
BUGREPORT_MODULES = [
|
||||
BUGREPORT_MODULES: list[type[MVTModule]] = [
|
||||
DumpsysAccessibility,
|
||||
DumpsysActivities,
|
||||
DumpsysAppops,
|
||||
@@ -31,4 +36,7 @@ BUGREPORT_MODULES = [
|
||||
DumpsysADBState,
|
||||
BugReportTimestamps,
|
||||
Tombstones,
|
||||
Processes,
|
||||
Settings,
|
||||
Mounts,
|
||||
]
|
||||
@@ -72,7 +72,11 @@ class BugReportModule(MVTModule):
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
joined = os.path.join(self.extract_path, file_path)
|
||||
if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()):
|
||||
if (
|
||||
not Path(joined)
|
||||
.resolve()
|
||||
.is_relative_to(Path(self.extract_path).resolve())
|
||||
):
|
||||
raise ValueError("unsafe file_path")
|
||||
handle = open(joined, "rb")
|
||||
|
||||
@@ -100,6 +104,27 @@ class BugReportModule(MVTModule):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_command_section(content: str, heading: str) -> str:
|
||||
"""Return a bugreport command section without consuming the next one.
|
||||
|
||||
Bugreport separators include timing text, so looking for a line equal to
|
||||
``------`` is not sufficient and can accidentally feed the remainder of
|
||||
dumpstate to a parser.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
in_section = False
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if not in_section:
|
||||
if stripped.startswith(heading):
|
||||
in_section = True
|
||||
continue
|
||||
if stripped.startswith("------"):
|
||||
break
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
|
||||
if self.zip_archive:
|
||||
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
||||
|
||||
@@ -49,9 +49,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
||||
self.parse(content)
|
||||
|
||||
for result in self.results:
|
||||
self.log.info(
|
||||
'Found installed accessibility service "%s"', result.get("service")
|
||||
)
|
||||
self.log.info('Found accessibility service "%s"', result.get("component"))
|
||||
|
||||
self.log.info(
|
||||
"Identified a total of %d accessibility services", len(self.results)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -44,21 +44,8 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
|
||||
)
|
||||
return
|
||||
|
||||
lines = []
|
||||
in_getprop = False
|
||||
|
||||
for line in content.decode(errors="ignore").splitlines():
|
||||
if line.strip().startswith("------ SYSTEM PROPERTIES"):
|
||||
in_getprop = True
|
||||
continue
|
||||
|
||||
if not in_getprop:
|
||||
continue
|
||||
|
||||
if line.strip() == "------":
|
||||
break
|
||||
|
||||
lines.append(line)
|
||||
|
||||
self.parse("\n".join(lines))
|
||||
section = self.extract_command_section(
|
||||
content.decode(errors="ignore"), "------ SYSTEM PROPERTIES"
|
||||
)
|
||||
self.parse(section)
|
||||
self.log.info("Extracted %d Android system properties", len(self.results))
|
||||
@@ -50,7 +50,10 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
|
||||
|
||||
for result in self.results:
|
||||
dangerous_permissions_count = 0
|
||||
for perm in result["permissions"]:
|
||||
permissions = list(result["permissions"])
|
||||
for user in result.get("users", []):
|
||||
permissions.extend(user.get("permissions", []))
|
||||
for perm in permissions:
|
||||
if perm["name"] in DANGEROUS_PERMISSIONS:
|
||||
dangerous_permissions_count += 1
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
||||
"""This module extracts details on uninstalled apps."""
|
||||
"""This module extracts raw per-package compatibility overrides."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -48,4 +48,4 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
||||
)
|
||||
self.parse(content)
|
||||
|
||||
self.log.info("Found %d uninstalled apps", len(self.results))
|
||||
self.log.info("Found %d package compatibility overrides", len(self.results))
|
||||
@@ -33,7 +33,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.results = results if results else {}
|
||||
self.results = results if results else []
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
@@ -49,4 +49,4 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
self.parse(dumpsys_section)
|
||||
self.log.info("Extracted receivers for %d intents", len(self.results))
|
||||
self.log.info("Extracted %d package receivers", len(self.results))
|
||||
@@ -4,12 +4,15 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from .base import BugReportModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
|
||||
from mvt.android.artifacts.getprop import GetProp
|
||||
|
||||
|
||||
class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
|
||||
@@ -38,15 +41,44 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
|
||||
def run(self) -> None:
|
||||
filesystem_files = self._get_files_by_pattern("FS/*")
|
||||
|
||||
timezone_name = None
|
||||
dumpstate = self._get_dumpstate_file()
|
||||
if dumpstate:
|
||||
section = self.extract_command_section(
|
||||
dumpstate.decode("utf-8", errors="replace"),
|
||||
"------ SYSTEM PROPERTIES",
|
||||
)
|
||||
properties = GetProp()
|
||||
properties.parse(section)
|
||||
timezone_name = properties.get_device_timezone()
|
||||
timezone = None
|
||||
if timezone_name:
|
||||
try:
|
||||
timezone = ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
self.log.warning("Unknown device timezone %s", timezone_name)
|
||||
|
||||
self.results = []
|
||||
for file in filesystem_files:
|
||||
# Only the modification time is available in the zip file metadata.
|
||||
# The timezone is the local timezone of the machine the phone.
|
||||
modification_time = self._get_file_modification_time(file)
|
||||
utc_time = None
|
||||
if timezone is not None:
|
||||
utc_time = convert_datetime_to_iso(
|
||||
modification_time.replace(tzinfo=timezone).astimezone(
|
||||
datetime.timezone.utc
|
||||
)
|
||||
)
|
||||
self.results.append(
|
||||
{
|
||||
"path": file,
|
||||
"modified_time": convert_datetime_to_iso(modification_time),
|
||||
"modified_time_utc": utc_time,
|
||||
"timezone": timezone_name,
|
||||
"timestamp_source": (
|
||||
"zip_metadata" if self.zip_archive else "filesystem_metadata"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
import re
|
||||
|
||||
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Mounts(MountsArtifact, BugReportModule):
|
||||
"""Extract and deduplicate process mount namespaces from mountinfo."""
|
||||
|
||||
def run(self) -> None:
|
||||
mount_files = self._get_files_by_pattern("FS/proc/*/mountinfo")
|
||||
if not mount_files:
|
||||
mount_files = self._get_files_by_pattern("*/proc/*/mountinfo")
|
||||
unique: dict[tuple, dict] = {}
|
||||
for file_path in mount_files:
|
||||
pid_match = re.search(r"/proc/(\d+)/mountinfo$", file_path)
|
||||
if not pid_match:
|
||||
continue
|
||||
entries = self.parse_mountinfo(
|
||||
self._get_file_content(file_path).decode("utf-8", errors="replace"),
|
||||
int(pid_match.group(1)),
|
||||
)
|
||||
for entry in entries:
|
||||
identity = (
|
||||
entry["major_minor"],
|
||||
entry["root"],
|
||||
entry["mount_point"],
|
||||
entry["device"],
|
||||
entry["filesystem_type"],
|
||||
entry["mount_options"],
|
||||
)
|
||||
if identity in unique:
|
||||
unique[identity]["process_ids"].extend(entry["process_ids"])
|
||||
else:
|
||||
unique[identity] = entry
|
||||
self.results = list(unique.values())
|
||||
self.log.info("Extracted %d unique mount records", len(self.results))
|
||||
@@ -0,0 +1,22 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
from mvt.android.artifacts.processes import Processes as ProcessesArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Processes(ProcessesArtifact, BugReportModule):
|
||||
"""Extract the process and thread table from dumpstate."""
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
self.log.error("Unable to find dumpstate file")
|
||||
return
|
||||
section = self.extract_command_section(
|
||||
data.decode("utf-8", errors="replace"),
|
||||
"------ PROCESSES AND THREADS",
|
||||
)
|
||||
self.parse(section)
|
||||
self.log.info("Identified %d running process threads", len(self.results))
|
||||
@@ -0,0 +1,22 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Settings(SettingsArtifact, BugReportModule):
|
||||
"""Extract all SettingsProvider namespaces and users."""
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
self.log.error("Unable to find dumpstate file")
|
||||
return
|
||||
section = self.extract_dumpsys_section(
|
||||
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:"
|
||||
)
|
||||
self.parse(section)
|
||||
count = sum(len(settings) for settings in self.results.values())
|
||||
self.log.info("Identified %d Android settings", count)
|
||||
@@ -43,21 +43,76 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
|
||||
)
|
||||
return
|
||||
|
||||
for tombstone_file in sorted(tombstone_files):
|
||||
tombstone_filename = tombstone_file.split("/")[-1]
|
||||
modification_time = self._get_file_modification_time(tombstone_file)
|
||||
tombstone_data = self._get_file_content(tombstone_file)
|
||||
grouped: dict[str, dict[str, str]] = {}
|
||||
for file_path in tombstone_files:
|
||||
file_name = file_path.rsplit("/", 1)[-1]
|
||||
source = "protobuf" if file_name.endswith(".pb") else "text"
|
||||
crash_id = file_name.removesuffix(".pb")
|
||||
grouped.setdefault(crash_id, {})[source] = file_path
|
||||
|
||||
try:
|
||||
if tombstone_file.endswith(".pb"):
|
||||
self.parse_protobuf(
|
||||
tombstone_filename, modification_time, tombstone_data
|
||||
for crash_id, paths in sorted(grouped.items()):
|
||||
parsed_sources: dict[str, dict] = {}
|
||||
source_records: dict[str, dict] = {}
|
||||
for source in ("text", "protobuf"):
|
||||
file_path = paths.get(source)
|
||||
if file_path is None:
|
||||
continue
|
||||
file_name = file_path.rsplit("/", 1)[-1]
|
||||
file_timestamp = self._get_file_modification_time(file_path)
|
||||
source_info = {
|
||||
"file_name": file_name,
|
||||
"file_timestamp": file_timestamp.isoformat(),
|
||||
"parsed": False,
|
||||
"error": None,
|
||||
"record": None,
|
||||
}
|
||||
try:
|
||||
data = self._get_file_content(file_path)
|
||||
if source == "protobuf":
|
||||
record = self.parse_protobuf_record(
|
||||
file_name, file_timestamp, data
|
||||
)
|
||||
else:
|
||||
record = self.parse_text_record(file_name, file_timestamp, data)
|
||||
source_info["parsed"] = True
|
||||
source_info["record"] = record
|
||||
source_records[source] = record
|
||||
except Exception as exc:
|
||||
source_info["error"] = str(exc)
|
||||
self.log.error(
|
||||
"Error parsing tombstone file %s: %s", file_path, exc
|
||||
)
|
||||
else:
|
||||
self.parse(tombstone_filename, modification_time, tombstone_data)
|
||||
except ValueError as e:
|
||||
# Catch any exceptions raised during parsing or validation.
|
||||
self.log.error(f"Error parsing tombstone file {tombstone_file}: {e}")
|
||||
parsed_sources[source] = source_info
|
||||
|
||||
if not source_records:
|
||||
continue
|
||||
preferred = source_records.get("protobuf") or source_records["text"]
|
||||
canonical = dict(preferred)
|
||||
text_record = source_records.get("text")
|
||||
if text_record:
|
||||
for key, value in text_record.items():
|
||||
if canonical.get(key) in (None, "", [], {}):
|
||||
canonical[key] = value
|
||||
|
||||
differences = {}
|
||||
protobuf_record = source_records.get("protobuf")
|
||||
if text_record and protobuf_record:
|
||||
for key in text_record.keys() & protobuf_record.keys():
|
||||
if key in ("file_name", "file_timestamp"):
|
||||
continue
|
||||
if text_record[key] != protobuf_record[key]:
|
||||
differences[key] = {
|
||||
"text": text_record[key],
|
||||
"protobuf": protobuf_record[key],
|
||||
}
|
||||
canonical.update(
|
||||
{
|
||||
"crash_id": crash_id,
|
||||
"sources": parsed_sources,
|
||||
"differences": differences,
|
||||
}
|
||||
)
|
||||
self.results.append(canonical)
|
||||
|
||||
self.log.info(
|
||||
"Extracted a total of %d tombstone files",
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .connect_event import ConnectEvent
|
||||
from .dns_event import DnsEvent
|
||||
from .security_event import SecurityEvent
|
||||
|
||||
INTRUSION_LOGS_MODULES = [
|
||||
INTRUSION_LOGS_MODULES: list[type[MVTModule]] = [
|
||||
DnsEvent,
|
||||
ConnectEvent,
|
||||
SecurityEvent,
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import click
|
||||
|
||||
from mvt.common.cli_plugins import (
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
load_cli_commands_option,
|
||||
register_cli_plugins,
|
||||
)
|
||||
from mvt.common.cmd_plugins import plugins
|
||||
from mvt.common.completion import completion
|
||||
from mvt.common.help import (
|
||||
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
HELP_MSG_DISABLE_UPDATE_CHECK,
|
||||
HELP_MSG_STIX2,
|
||||
HELP_MSG_VERBOSE,
|
||||
HELP_MSG_VERSION,
|
||||
)
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
from mvt.common.utils import init_logging, set_verbose_logging
|
||||
|
||||
init_logging()
|
||||
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Main
|
||||
# ==============================================================================
|
||||
@click.group(invoke_without_command=True)
|
||||
@load_cli_commands_option
|
||||
@click.option(
|
||||
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
|
||||
)
|
||||
@click.option(
|
||||
"--disable-indicator-update-check",
|
||||
is_flag=True,
|
||||
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.pass_context
|
||||
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
"""Mobile Verification Toolkit.
|
||||
|
||||
The 'mvt-ios' and 'mvt-android' CLI commands are used to perform
|
||||
forensic analysis on IOS and Android devices.
|
||||
"""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["disable_version_check"] = disable_update_check
|
||||
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
if ctx.invoked_subcommand != "completion":
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
if ctx.invoked_subcommand is None:
|
||||
click.echo(ctx.get_help())
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: download-iocs
|
||||
# ==============================================================================
|
||||
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
|
||||
def download_iocs():
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
ioc_updates.update()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: completion
|
||||
# ==============================================================================
|
||||
cli.add_command(completion)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: version
|
||||
# ==============================================================================
|
||||
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
|
||||
def version():
|
||||
return
|
||||
|
||||
|
||||
# The plugins command is registered as a built-in command, before any external
|
||||
# command, so that an installed package can never replace it.
|
||||
cli.add_command(plugins)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Entry point of the mvt console script
|
||||
# ==============================================================================
|
||||
def main() -> None:
|
||||
"""Register the external commands and run the mvt CLI.
|
||||
|
||||
External commands are registered here rather than when this module is
|
||||
imported, so that importing MVT never runs third-party code and a plugin
|
||||
importing from MVT cannot re-enter a module that is still initializing.
|
||||
"""
|
||||
register_cli_plugins(
|
||||
cli,
|
||||
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
cli()
|
||||
@@ -0,0 +1,317 @@
|
||||
# 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
|
||||
|
||||
# This module is imported by every CLI at start-up, and by shell completion on
|
||||
# every keystroke, so it must stay cheap: nothing here may import the module
|
||||
# machinery (mvt.common.module_loader and what it pulls in).
|
||||
|
||||
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
|
||||
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
|
||||
# Commands in this group are registered on the platform-neutral mvt command only.
|
||||
NEUTRAL_CLI_PLUGIN_GROUP = "mvt.cli_plugins"
|
||||
MVT_CUSTOM_COMMANDS_ENV = "MVT_CUSTOM_COMMANDS"
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
|
||||
# Prefix of the import name given to a command file loaded from a path. Shared
|
||||
# with module_loader, which recognises such files when naming their loggers.
|
||||
CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_"
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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"{CUSTOM_COMMAND_MODULE_PREFIX}{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:
|
||||
"""Register the external commands of one CLI on its group.
|
||||
|
||||
Each CLI has one entry-point group and one environment variable of its
|
||||
own, so a command package chooses the CLIs its commands are added to.
|
||||
|
||||
:param group: CLI group to register the external commands on.
|
||||
:param entry_point_group: Entry-point group of the CLI.
|
||||
:param environment_variable: Name of the environment variable holding a
|
||||
path to load commands from.
|
||||
"""
|
||||
environment_path = os.environ.get(environment_variable)
|
||||
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)"
|
||||
),
|
||||
)
|
||||
@@ -9,6 +9,7 @@ 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__)
|
||||
@@ -76,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")
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import importlib.metadata
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from .cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
)
|
||||
from .config import settings
|
||||
from .help import (
|
||||
HELP_MSG_PLUGINS,
|
||||
HELP_MSG_PLUGINS_CHECK_UPDATES,
|
||||
HELP_MSG_PLUGINS_LIST,
|
||||
)
|
||||
from .module import MVTModule
|
||||
from .module_loader import MODULES_ENTRY_POINT_GROUP, distribution_direct_url
|
||||
from .updates import (
|
||||
SHORT_COMMIT_LENGTH,
|
||||
PluginUpdates,
|
||||
installed_plugin_distributions,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CLI_PLUGIN_GROUPS = (
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
)
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
def _entry_points(group: str) -> list[importlib.metadata.EntryPoint]:
|
||||
try:
|
||||
return list(importlib.metadata.entry_points(group=group))
|
||||
except Exception as exc:
|
||||
log.warning("Unable to discover the entry points in group %s: %s", group, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _entry_point_distribution(
|
||||
entry_point: importlib.metadata.EntryPoint,
|
||||
) -> Optional[str]:
|
||||
dist = getattr(entry_point, "dist", None)
|
||||
if dist is None:
|
||||
return None
|
||||
try:
|
||||
return dist.name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _distribution_version(dist: importlib.metadata.Distribution) -> str:
|
||||
try:
|
||||
return dist.version or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _distribution_origin(dist: importlib.metadata.Distribution) -> str:
|
||||
"""Describe where a plugin package was installed from."""
|
||||
direct_url = distribution_direct_url(dist)
|
||||
if direct_url is None:
|
||||
return "pypi"
|
||||
|
||||
vcs_info = direct_url.get("vcs_info")
|
||||
if isinstance(vcs_info, dict):
|
||||
commit = vcs_info.get("commit_id") or ""
|
||||
if commit:
|
||||
return f"git+{commit[:SHORT_COMMIT_LENGTH]}"
|
||||
return "git"
|
||||
|
||||
return "local"
|
||||
|
||||
|
||||
def _contributed_modules(
|
||||
entry_points: list[importlib.metadata.EntryPoint], distribution: str
|
||||
) -> str:
|
||||
"""Count the forensic modules a plugin package contributes.
|
||||
|
||||
Entry points are resolved the way MVT resolves them when it loads
|
||||
modules, but a broken entry point is reported instead of raising: listing
|
||||
the installed plugins must work even when one of them is faulty.
|
||||
"""
|
||||
count = 0
|
||||
broken = False
|
||||
|
||||
for entry_point in entry_points:
|
||||
if _entry_point_distribution(entry_point) != distribution:
|
||||
continue
|
||||
try:
|
||||
loaded = entry_point.load()
|
||||
if callable(loaded) and not isinstance(loaded, type):
|
||||
loaded = loaded()
|
||||
count += sum(
|
||||
1
|
||||
for module in loaded
|
||||
if isinstance(module, type) and issubclass(module, MVTModule)
|
||||
)
|
||||
except (Exception, SystemExit) as exc:
|
||||
log.debug(
|
||||
"Unable to load the modules of entry point %s (%s): %s",
|
||||
entry_point.name,
|
||||
entry_point.value,
|
||||
exc,
|
||||
)
|
||||
broken = True
|
||||
|
||||
if broken:
|
||||
return f"{count} (error)" if count else "error"
|
||||
|
||||
return str(count)
|
||||
|
||||
|
||||
def _contributed_commands(
|
||||
entry_points: list[importlib.metadata.EntryPoint], distribution: str
|
||||
) -> str:
|
||||
names = {
|
||||
entry_point.name
|
||||
for entry_point in entry_points
|
||||
if _entry_point_distribution(entry_point) == distribution
|
||||
}
|
||||
|
||||
return ", ".join(sorted(names)) if names else "-"
|
||||
|
||||
|
||||
@click.group("plugins", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS)
|
||||
def plugins() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@plugins.command("list", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS_LIST)
|
||||
def list_plugins() -> None:
|
||||
distributions = installed_plugin_distributions()
|
||||
if not distributions:
|
||||
click.echo("No MVT plugins are installed.")
|
||||
return
|
||||
|
||||
module_entry_points = _entry_points(MODULES_ENTRY_POINT_GROUP)
|
||||
command_entry_points = []
|
||||
for group in CLI_PLUGIN_GROUPS:
|
||||
command_entry_points.extend(_entry_points(group))
|
||||
|
||||
table = Table(title="Installed MVT plugins")
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Version")
|
||||
table.add_column("Origin")
|
||||
table.add_column("Modules", justify="right")
|
||||
table.add_column("Commands")
|
||||
|
||||
for dist in distributions:
|
||||
name = dist.name
|
||||
table.add_row(
|
||||
name,
|
||||
_distribution_version(dist),
|
||||
_distribution_origin(dist),
|
||||
_contributed_modules(module_entry_points, name),
|
||||
_contributed_commands(command_entry_points, name),
|
||||
)
|
||||
|
||||
Console().print(table)
|
||||
|
||||
|
||||
@plugins.command(
|
||||
"check-updates",
|
||||
context_settings=CONTEXT_SETTINGS,
|
||||
help=HELP_MSG_PLUGINS_CHECK_UPDATES,
|
||||
short_help="Check the installed plugins for updates",
|
||||
)
|
||||
def check_plugin_updates() -> None:
|
||||
if not settings.NETWORK_ACCESS_ALLOWED:
|
||||
click.echo(
|
||||
"Network access is disabled, cannot check for plugin updates. "
|
||||
"Enable NETWORK_ACCESS_ALLOWED in the MVT configuration to check."
|
||||
)
|
||||
return
|
||||
|
||||
if not installed_plugin_distributions():
|
||||
click.echo("No MVT plugins are installed.")
|
||||
return
|
||||
|
||||
findings = PluginUpdates().check()
|
||||
if not findings:
|
||||
click.echo("All plugins are up to date.")
|
||||
return
|
||||
|
||||
click.echo("Plugin updates available:")
|
||||
for finding in findings:
|
||||
click.echo(f" {finding['name']} {finding['installed']} → {finding['latest']}")
|
||||
click.echo(f" Upgrade with: {finding['upgrade_command']}")
|
||||
|
||||
click.echo(
|
||||
"\nMVT does not install plugin updates. Run the command above when you "
|
||||
"decide to upgrade."
|
||||
)
|
||||
+402
-32
@@ -19,8 +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 module_supports_command
|
||||
from .module_types import ModuleTimeline
|
||||
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,
|
||||
@@ -67,12 +72,17 @@ class Command:
|
||||
# down a password to decrypt a backup or flags which are need by some modules.
|
||||
self.module_options = module_options if module_options else {}
|
||||
|
||||
# This dictionary maps the modules which were replaced by a module
|
||||
# declaring `replaces` to the module which took their place.
|
||||
self.module_replacements: dict[type[MVTModule], type[MVTModule]] = {}
|
||||
|
||||
# This list will contain all executed modules.
|
||||
# We can use this to reference e.g. self.executed[0].results.
|
||||
self.executed: list[MVTModule] = []
|
||||
self.hashes = hashes
|
||||
self.hash_values: list[dict[str, Any]] = []
|
||||
self.timeline: ModuleTimeline = []
|
||||
self.url_results: list[URLResult] = []
|
||||
|
||||
# Load IOCs
|
||||
self._create_storage()
|
||||
@@ -150,6 +160,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
|
||||
@@ -201,10 +219,34 @@ 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._available_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)
|
||||
@@ -219,7 +261,174 @@ class Command:
|
||||
if module not in deduplicated:
|
||||
deduplicated.append(module)
|
||||
|
||||
return deduplicated
|
||||
available = self._apply_replacements(deduplicated)
|
||||
self._warn_about_slug_collisions(available)
|
||||
|
||||
return available
|
||||
|
||||
def _warn_about_slug_collisions(self, modules: list[type[MVTModule]]) -> None:
|
||||
"""Report modules writing their results to the same file.
|
||||
|
||||
Results are stored in a file named after the module slug, so two
|
||||
modules sharing one silently overwrite each other. Replacements are
|
||||
already resolved here, so a module deliberately taking over the slug
|
||||
of the module it replaces is not reported: the module it replaced is
|
||||
no longer part of the run.
|
||||
"""
|
||||
modules_by_slug: dict[str, type[MVTModule]] = {}
|
||||
for module in modules:
|
||||
slug = module.get_slug()
|
||||
first = modules_by_slug.setdefault(slug, module)
|
||||
if first is module:
|
||||
continue
|
||||
|
||||
self.log.warning(
|
||||
"Modules %s from %s and %s from %s both use the slug %s. If "
|
||||
"both run, whichever runs last overwrites the results of the "
|
||||
"other in %s.json.",
|
||||
first.__name__,
|
||||
get_module_origin(first).label,
|
||||
module.__name__,
|
||||
get_module_origin(module).label,
|
||||
slug,
|
||||
slug,
|
||||
)
|
||||
|
||||
def _declared_replacements(
|
||||
self, modules: list[type[MVTModule]]
|
||||
) -> dict[type[MVTModule], type[MVTModule]]:
|
||||
"""Return the replacements declared by the given modules."""
|
||||
replacements: dict[type[MVTModule], type[MVTModule]] = {}
|
||||
for module in modules:
|
||||
replaced = module.replaces
|
||||
if replaced is None or replaced is module:
|
||||
continue
|
||||
|
||||
if not module.enabled:
|
||||
# Replacing a module must not disable it, as a disabled
|
||||
# replacement never runs in its place.
|
||||
self.log.debug(
|
||||
"Module %s is disabled and does not replace module %s.",
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
if replaced not in modules:
|
||||
# A module can support several commands while the module it
|
||||
# replaces is only available in some of them.
|
||||
self.log.debug(
|
||||
"Module %s replaces module %s, which is not available "
|
||||
"for the %s command.",
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
self.name,
|
||||
)
|
||||
continue
|
||||
|
||||
if replaced in replacements:
|
||||
self.log.warning(
|
||||
"Modules %s and %s both replace module %s. Both of them "
|
||||
"will run, %s will not, and modules depending on %s will "
|
||||
"use the results of %s. Replacements which share the slug "
|
||||
"of %s overwrite each other's results file.",
|
||||
replacements[replaced].__name__,
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
replaced.__name__,
|
||||
replaced.__name__,
|
||||
replacements[replaced].__name__,
|
||||
replaced.__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
replacements[replaced] = module
|
||||
|
||||
return replacements
|
||||
|
||||
def _drop_replacement_cycles(
|
||||
self, replacements: dict[type[MVTModule], type[MVTModule]]
|
||||
) -> None:
|
||||
"""Undo the replacements between modules which replace each other."""
|
||||
cyclic: set[type[MVTModule]] = set()
|
||||
for replaced in replacements:
|
||||
walked: list[type[MVTModule]] = []
|
||||
module = replaced
|
||||
while module in replacements and module not in cyclic:
|
||||
if module in walked:
|
||||
cyclic.update(walked[walked.index(module) :])
|
||||
break
|
||||
walked.append(module)
|
||||
module = replacements[module]
|
||||
|
||||
if not cyclic:
|
||||
return
|
||||
|
||||
self.log.warning(
|
||||
"Modules %s replace each other in a cycle. None of them replaces "
|
||||
"anything and all of them will run.",
|
||||
", ".join(sorted(module.__name__ for module in cyclic)),
|
||||
)
|
||||
for module in cyclic:
|
||||
replacements.pop(module, None)
|
||||
|
||||
def _log_replacement(
|
||||
self,
|
||||
replaced: type[MVTModule],
|
||||
module: type[MVTModule],
|
||||
replacement: type[MVTModule],
|
||||
) -> None:
|
||||
"""Report an applied replacement, and any problem with it."""
|
||||
if not issubclass(module, replaced):
|
||||
self.log.warning(
|
||||
"Module %s replaces module %s but is not a subclass of it. "
|
||||
"Its results might not be compatible with what modules "
|
||||
"depending on %s expect.",
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
replaced.__name__,
|
||||
)
|
||||
|
||||
if replaced in module.dependencies:
|
||||
self.log.warning(
|
||||
"Module %s depends on module %s, which it also replaces. The "
|
||||
"dependency cannot be satisfied: a replacement has to produce "
|
||||
"that data itself.",
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Module %s from %s replaces module %s from %s.",
|
||||
replacement.__name__,
|
||||
get_module_origin(replacement).label,
|
||||
replaced.__name__,
|
||||
get_module_origin(replaced).label,
|
||||
)
|
||||
|
||||
def _apply_replacements(
|
||||
self, modules: list[type[MVTModule]]
|
||||
) -> list[type[MVTModule]]:
|
||||
"""Drop the modules superseded by a module declaring `replaces`."""
|
||||
declared = self._declared_replacements(modules)
|
||||
self._drop_replacement_cycles(declared)
|
||||
|
||||
replacements: dict[type[MVTModule], type[MVTModule]] = {}
|
||||
for replaced, module in declared.items():
|
||||
# Follow chains of replacements, so that a dependency on a
|
||||
# replaced module always resolves to a module which is part of
|
||||
# the run.
|
||||
replacement = module
|
||||
while replacement in declared:
|
||||
replacement = declared[replacement]
|
||||
|
||||
# Only the replacements which are applied are reported, so that
|
||||
# the record matches the modules which actually run.
|
||||
self._log_replacement(replaced, module, replacement)
|
||||
replacements[replaced] = replacement
|
||||
|
||||
self.module_replacements = replacements
|
||||
return [module for module in modules if module not in replacements]
|
||||
|
||||
def init(self) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -279,42 +488,196 @@ class Command:
|
||||
console.print("")
|
||||
console.print(panel)
|
||||
|
||||
def _module_dependencies(
|
||||
self, module: type[MVTModule]
|
||||
) -> list[tuple[type[MVTModule], type[MVTModule]]]:
|
||||
"""Return the (declared, resolved) dependencies of a module.
|
||||
|
||||
A dependency on a module which was replaced is resolved to the module
|
||||
which took its place. A module which replaces one of its own
|
||||
dependencies is not made to depend on itself, while a module which
|
||||
declares itself as a dependency is left alone and still fails the
|
||||
circular dependency check.
|
||||
"""
|
||||
dependencies = []
|
||||
for dependency in module.dependencies:
|
||||
resolved = self.module_replacements.get(dependency, dependency)
|
||||
if resolved is module and dependency is not module:
|
||||
continue
|
||||
dependencies.append((dependency, resolved))
|
||||
|
||||
return dependencies
|
||||
|
||||
@staticmethod
|
||||
def _dependency_name(
|
||||
declared: type[MVTModule], resolved: type[MVTModule]
|
||||
) -> str:
|
||||
"""Return how a dependency is named in the messages about it.
|
||||
|
||||
A dependency is named as the module which declared it wrote it, and,
|
||||
when that module was replaced, as the module which runs in its place.
|
||||
"""
|
||||
if declared is resolved:
|
||||
return declared.__name__
|
||||
|
||||
return f"{resolved.__name__} (replacing module {declared.__name__})"
|
||||
|
||||
def _selected_modules(
|
||||
self, modules: list[type[MVTModule]]
|
||||
) -> Optional[list[type[MVTModule]]]:
|
||||
"""Return the modules explicitly requested, or all the enabled ones.
|
||||
|
||||
Returns None when a module was requested by name and no module of
|
||||
that name can be run.
|
||||
"""
|
||||
if not self.module_name:
|
||||
return [module for module in modules if module.enabled]
|
||||
|
||||
selected = [
|
||||
module for module in modules if module.__name__ == self.module_name
|
||||
]
|
||||
|
||||
# A module replacing another one does not have to keep its name, so
|
||||
# the name of a replaced module selects its replacement.
|
||||
if not selected:
|
||||
for replaced, replacement in self.module_replacements.items():
|
||||
if replaced.__name__ != self.module_name or replacement in selected:
|
||||
continue
|
||||
self.log.info(
|
||||
"Module %s was replaced by module %s, which is run "
|
||||
"in its place.",
|
||||
replaced.__name__,
|
||||
replacement.__name__,
|
||||
)
|
||||
selected.append(replacement)
|
||||
|
||||
if not selected:
|
||||
self.log.warning(
|
||||
"No module named %s is available for the %s command. "
|
||||
"No modules will be run.",
|
||||
self.module_name,
|
||||
self.name,
|
||||
)
|
||||
return None
|
||||
|
||||
return selected
|
||||
|
||||
def _skipped_modules(
|
||||
self,
|
||||
required: list[type[MVTModule]],
|
||||
module_indexes: dict[type[MVTModule], int],
|
||||
) -> dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]]:
|
||||
"""Return the modules to drop because a dependency is unavailable.
|
||||
|
||||
A module declaring a dependency this command cannot provide is unable
|
||||
to run, and so is every module depending on it. Dropping only those
|
||||
keeps a single wrong declaration - in a module scoped to several
|
||||
commands, for example - from silencing an entire analysis.
|
||||
|
||||
The returned mapping gives, for each skipped module, the module which
|
||||
is missing a dependency and the dependency it is missing.
|
||||
"""
|
||||
skipped: dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]] = {}
|
||||
# Skipping the one module a run was asked for leaves nothing to run,
|
||||
# which the caller reports instead.
|
||||
remainder = (
|
||||
"" if self.module_name else " The rest of the analysis will still run."
|
||||
)
|
||||
|
||||
# Skipping one module can skip the modules depending on it, which the
|
||||
# pass over the module list may already have gone past, so repeat the
|
||||
# pass until nothing changes.
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for module in required:
|
||||
if module in skipped:
|
||||
continue
|
||||
|
||||
for declared, dependency in self._module_dependencies(module):
|
||||
if dependency not in module_indexes:
|
||||
# A replaced dependency always resolves to a module of
|
||||
# this command, so an unavailable one is always the
|
||||
# module class the author declared.
|
||||
skipped[module] = (module, declared)
|
||||
changed = True
|
||||
self.log.warning(
|
||||
"Module %s will be SKIPPED: it depends on module "
|
||||
"%s, which is not available in this command.%s",
|
||||
module.__name__,
|
||||
declared.__name__,
|
||||
remainder,
|
||||
)
|
||||
break
|
||||
|
||||
if dependency in skipped:
|
||||
root, missing = skipped[dependency]
|
||||
skipped[module] = (root, missing)
|
||||
changed = True
|
||||
if dependency is root:
|
||||
self.log.warning(
|
||||
"Module %s will be SKIPPED: it depends on "
|
||||
"module %s, itself skipped for depending on "
|
||||
"unavailable module %s.%s",
|
||||
module.__name__,
|
||||
self._dependency_name(declared, dependency),
|
||||
missing.__name__,
|
||||
remainder,
|
||||
)
|
||||
else:
|
||||
self.log.warning(
|
||||
"Module %s will be SKIPPED: it depends on "
|
||||
"skipped module %s, in a chain starting at "
|
||||
"module %s, which depends on unavailable "
|
||||
"module %s.%s",
|
||||
module.__name__,
|
||||
self._dependency_name(declared, dependency),
|
||||
root.__name__,
|
||||
missing.__name__,
|
||||
remainder,
|
||||
)
|
||||
break
|
||||
|
||||
return skipped
|
||||
|
||||
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
|
||||
"""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]
|
||||
selected = self._selected_modules(modules)
|
||||
if selected is None:
|
||||
return None
|
||||
|
||||
required = set(selected)
|
||||
required: set[type[MVTModule]] = set()
|
||||
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)
|
||||
if module in required:
|
||||
continue
|
||||
required.add(module)
|
||||
for _, dependency in self._module_dependencies(module):
|
||||
# Unavailable dependencies are reported by _skipped_modules().
|
||||
if dependency in module_indexes:
|
||||
pending.append(dependency)
|
||||
|
||||
ordered_required = sorted(required, key=lambda module: module_indexes[module])
|
||||
skipped = self._skipped_modules(ordered_required, module_indexes)
|
||||
runnable = [module for module in ordered_required if module not in skipped]
|
||||
if skipped and not runnable:
|
||||
self.log.warning(
|
||||
"Every selected module was skipped for an unavailable "
|
||||
"dependency. No modules will be run."
|
||||
)
|
||||
return None
|
||||
|
||||
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
|
||||
module: [] for module in required
|
||||
module: [] for module in runnable
|
||||
}
|
||||
indegree = {module: 0 for module in required}
|
||||
for module in required:
|
||||
for dependency in module.dependencies:
|
||||
if dependency not in required:
|
||||
indegree = {module: 0 for module in runnable}
|
||||
for module in runnable:
|
||||
for _, dependency in self._module_dependencies(module):
|
||||
if dependency not in indegree:
|
||||
continue
|
||||
dependents[dependency].append(module)
|
||||
indegree[module] += 1
|
||||
@@ -333,7 +696,7 @@ class Command:
|
||||
if indegree[dependent] == 0:
|
||||
heappush(ready, (module_indexes[dependent], dependent))
|
||||
|
||||
if len(ordered) != len(required):
|
||||
if len(ordered) != len(runnable):
|
||||
cyclic_modules = sorted(
|
||||
(module.__name__ for module, count in indegree.items() if count > 0)
|
||||
)
|
||||
@@ -351,6 +714,8 @@ class Command:
|
||||
if ordered_modules is None:
|
||||
return
|
||||
|
||||
self._log_loaded_modules(ordered_modules)
|
||||
|
||||
try:
|
||||
self.init()
|
||||
except NotImplementedError:
|
||||
@@ -359,7 +724,7 @@ class Command:
|
||||
executed_by_type: dict[type[MVTModule], MVTModule] = {}
|
||||
for module in ordered_modules:
|
||||
|
||||
module_logger = logging.getLogger(module.__module__)
|
||||
module_logger = get_module_logger(module)
|
||||
|
||||
m = module(
|
||||
target_path=self.target_path,
|
||||
@@ -367,9 +732,12 @@ class Command:
|
||||
module_options=self.module_options,
|
||||
log=module_logger,
|
||||
)
|
||||
# Dependencies are keyed by the module class they declare, even
|
||||
# when it was replaced, so that a module asking for the results
|
||||
# of a replaced module receives those of its replacement.
|
||||
m.dependency_modules = {
|
||||
dependency: executed_by_type[dependency]
|
||||
for dependency in module.dependencies
|
||||
dependency: executed_by_type[resolved]
|
||||
for dependency, resolved in self._module_dependencies(module)
|
||||
}
|
||||
|
||||
if self.iocs.total_ioc_count:
|
||||
@@ -396,6 +764,7 @@ class Command:
|
||||
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:
|
||||
@@ -410,4 +779,5 @@ class Command:
|
||||
self._store_timeline()
|
||||
self._store_alerts_timeline()
|
||||
self._store_alerts()
|
||||
self._store_urls()
|
||||
self._store_info()
|
||||
@@ -9,34 +9,83 @@ import shlex
|
||||
import click
|
||||
from click.shell_completion import get_completion_class
|
||||
|
||||
from .help import HELP_MSG_COMPLETION
|
||||
|
||||
|
||||
SUPPORTED_SHELLS = ("bash", "zsh", "fish")
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
COMPLETION_INSTRUCTIONS = """Shell completion for mvt, mvt-ios and mvt-android
|
||||
|
||||
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
|
||||
Print one completion script covering the three commands:
|
||||
mvt completion bash > ~/.mvt-complete.bash
|
||||
mvt completion zsh > ~/.mvt-complete.zsh
|
||||
mkdir -p ~/.config/fish/conf.d
|
||||
mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish
|
||||
|
||||
Load the generated Bash script from ~/.bashrc:
|
||||
[ -f ~/.{program_name}-complete.bash ] && . ~/.{program_name}-complete.bash
|
||||
[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash
|
||||
|
||||
Load the generated Zsh script from ~/.zshrc:
|
||||
[ -f ~/.{program_name}-complete.zsh ] && . ~/.{program_name}-complete.zsh
|
||||
[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-complete.zsh
|
||||
|
||||
Fish loads completion files from ~/.config/fish/completions automatically.
|
||||
Fish loads the files in ~/.config/fish/conf.d 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
|
||||
To write these files and update the Bash/Zsh shell configuration automatically:
|
||||
mvt completion bash --install
|
||||
mvt completion zsh --install
|
||||
mvt completion fish --install
|
||||
"""
|
||||
|
||||
|
||||
def _mvt_programs() -> list[tuple[str, click.Command]]:
|
||||
"""Return the console script name and CLI group of every MVT program.
|
||||
|
||||
The three CLIs are imported here rather than at module level: mvt.cli
|
||||
imports this module while it is being defined, and generating a completion
|
||||
script should not make the start-up of `mvt` import the platform CLIs.
|
||||
"""
|
||||
from mvt.android.cli import cli as android_cli
|
||||
from mvt.cli import cli as mvt_cli
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
|
||||
return [("mvt", mvt_cli), ("mvt-ios", ios_cli), ("mvt-android", android_cli)]
|
||||
|
||||
|
||||
@click.command(
|
||||
"completion",
|
||||
context_settings=CONTEXT_SETTINGS,
|
||||
help=HELP_MSG_COMPLETION,
|
||||
short_help="Generate or install shell completion",
|
||||
)
|
||||
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
|
||||
@click.option(
|
||||
"--install",
|
||||
is_flag=True,
|
||||
help="Write completion files and update shell configuration.",
|
||||
)
|
||||
def completion(shell, install):
|
||||
if shell is None:
|
||||
if install:
|
||||
raise click.UsageError("A shell is required when using --install.")
|
||||
click.echo(COMPLETION_INSTRUCTIONS)
|
||||
return
|
||||
|
||||
if install:
|
||||
script_path = install_completion_script(shell)
|
||||
click.echo(
|
||||
f"Installed {shell} completion for mvt, mvt-ios and mvt-android "
|
||||
f"to {script_path}"
|
||||
)
|
||||
if shell in ("bash", "zsh"):
|
||||
click.echo(f"Updated ~/.{shell}rc")
|
||||
else:
|
||||
click.echo("Fish loads the files in ~/.config/fish/conf.d automatically.")
|
||||
return
|
||||
|
||||
click.echo(generate_mvt_completion_script(shell))
|
||||
|
||||
|
||||
def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str:
|
||||
completion_class = get_completion_class(shell)
|
||||
if completion_class is None:
|
||||
@@ -46,41 +95,49 @@ def generate_completion_script(cli: click.Command, program_name: str, shell: str
|
||||
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)
|
||||
def generate_mvt_completion_script(shell: str) -> str:
|
||||
"""Return one script completing every MVT command.
|
||||
|
||||
Click names the completion function of each program after the program, so
|
||||
the scripts of the three commands can simply be concatenated.
|
||||
"""
|
||||
scripts = [
|
||||
generate_completion_script(cli, program_name, shell).strip("\n")
|
||||
for program_name, cli in _mvt_programs()
|
||||
]
|
||||
return "\n\n".join(scripts)
|
||||
|
||||
|
||||
def install_completion_script(shell: str) -> Path:
|
||||
script = generate_mvt_completion_script(shell)
|
||||
script_path = _completion_script_path(shell)
|
||||
script_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
script_path.write_text(script, encoding="utf-8")
|
||||
script_path.write_text(f"{script}\n", encoding="utf-8")
|
||||
|
||||
if shell in ("bash", "zsh"):
|
||||
_install_shell_source_line(program_name, shell, script_path)
|
||||
_install_shell_source_line(shell, script_path)
|
||||
|
||||
return script_path
|
||||
|
||||
|
||||
def _completion_script_path(program_name: str, shell: str) -> Path:
|
||||
def _completion_script_path(shell: str) -> Path:
|
||||
home = Path.home()
|
||||
|
||||
if shell == "fish":
|
||||
return home / ".config" / "fish" / "completions" / f"{program_name}.fish"
|
||||
# conf.d is sourced when the shell starts, unlike the completions
|
||||
# folder, whose files fish loads on demand by command name.
|
||||
return home / ".config" / "fish" / "conf.d" / "mvt-completion.fish"
|
||||
|
||||
return home / f".{program_name}-complete.{shell}"
|
||||
return home / f".mvt-complete.{shell}"
|
||||
|
||||
|
||||
def _install_shell_source_line(program_name: str, shell: str, script_path: Path) -> None:
|
||||
def _install_shell_source_line(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"
|
||||
)
|
||||
block = f"# MVT shell completion\n{source_line}\n"
|
||||
|
||||
if shell_config_path.exists():
|
||||
shell_config = shell_config_path.read_text(encoding="utf-8")
|
||||
|
||||
+14
-2
@@ -8,7 +8,7 @@ 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 "
|
||||
@@ -17,17 +17,29 @@ HELP_MSG_LOAD_MODULE = (
|
||||
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"
|
||||
HELP_MSG_VERBOSE_COMMAND = (
|
||||
"Verbose mode (kept for compatibility, pass --verbose before the command "
|
||||
"name instead)"
|
||||
)
|
||||
HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators"
|
||||
HELP_MSG_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"
|
||||
HELP_MSG_COMPLETION = (
|
||||
"Generate or install shell completion for mvt, mvt-ios and mvt-android"
|
||||
)
|
||||
HELP_MSG_PLUGINS = "Inspect the installed MVT plugin packages"
|
||||
HELP_MSG_PLUGINS_LIST = "List the installed plugins and what they contribute to MVT"
|
||||
HELP_MSG_PLUGINS_CHECK_UPDATES = (
|
||||
"Check the installed plugins for updates without installing them"
|
||||
)
|
||||
|
||||
# IOS Specific
|
||||
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
|
||||
HELP_MSG_BACKUP_DESTINATION = (
|
||||
"Path to the folder where the decrypted backup should be stored"
|
||||
)
|
||||
HELP_MSG_DECRYPT_JOBS = "Number of files to decrypt concurrently"
|
||||
HELP_MSG_IOS_BACKUP_PASSWORD = (
|
||||
"Password to use to decrypt the backup (or, set the {MVT_IOS_BACKUP_PASSWORD} "
|
||||
"environment variable)"
|
||||
|
||||
@@ -49,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):
|
||||
@@ -439,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
|
||||
@@ -482,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.
|
||||
|
||||
|
||||
+125
-62
@@ -8,10 +8,125 @@ import logging
|
||||
import requests
|
||||
from rich import print as rich_print
|
||||
|
||||
from .updates import IndicatorsUpdates, MVTUpdates
|
||||
from .config import settings
|
||||
from .updates import (
|
||||
IndicatorsUpdates,
|
||||
MVTUpdates,
|
||||
PluginUpdates,
|
||||
installed_plugin_distributions,
|
||||
)
|
||||
from .version import MVT_VERSION
|
||||
|
||||
|
||||
def _check_version_updates(log: logging.Logger) -> None:
|
||||
try:
|
||||
mvt_updates = MVTUpdates()
|
||||
latest_version = mvt_updates.check()
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
rich_print(
|
||||
"\t[bold]Note: Could not check for MVT updates.[/bold] "
|
||||
"You may be working offline. Please update MVT regularly."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Error encountered when trying to check latest MVT version: %s", e)
|
||||
else:
|
||||
if latest_version:
|
||||
rich_print(
|
||||
f"\t[bold]Version {latest_version} is available! "
|
||||
"Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]"
|
||||
)
|
||||
|
||||
|
||||
def _check_indicator_updates(log: logging.Logger) -> None:
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
|
||||
# Before proceeding, we check if we have downloaded an indicators index.
|
||||
# If not, there's no point in proceeding with the updates check.
|
||||
if ioc_updates.get_latest_update() == 0:
|
||||
rich_print(
|
||||
"\t[bold]You have not yet downloaded any indicators, check "
|
||||
"the `download-iocs` command![/bold]"
|
||||
)
|
||||
return
|
||||
|
||||
# We only perform this check at a fixed frequency, in order to not
|
||||
# overburden the user with too many lookups if the command is being run
|
||||
# multiple times.
|
||||
should_check, hours = ioc_updates.should_check()
|
||||
if not should_check:
|
||||
rich_print(
|
||||
f"\tIndicators updates checked recently, next automatic check "
|
||||
f"in {int(hours)} hours"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
ioc_to_update = ioc_updates.check()
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
rich_print(
|
||||
"\t[bold]Note: Could not check for indicator updates.[/bold] "
|
||||
"You may be working offline. Please update MVT indicators regularly."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Error encountered when trying to check latest MVT indicators: %s", e)
|
||||
else:
|
||||
if ioc_to_update:
|
||||
rich_print(
|
||||
"\t[bold]There are updates to your indicators files! "
|
||||
"Run the `download-iocs` command to update![/bold]"
|
||||
)
|
||||
else:
|
||||
rich_print("\tYour indicators files seem to be up to date.")
|
||||
|
||||
|
||||
def _print_plugin_updates(findings: list) -> None:
|
||||
if not findings:
|
||||
return
|
||||
|
||||
rich_print("\t[bold]Plugin updates available:[/bold]")
|
||||
for finding in findings:
|
||||
rich_print(
|
||||
f"\t {finding['name']} {finding['installed']} → "
|
||||
f"{finding['latest']} ({finding['upgrade_command']})"
|
||||
)
|
||||
|
||||
|
||||
def _check_plugin_updates(log: logging.Logger) -> None:
|
||||
if not settings.NETWORK_ACCESS_ALLOWED:
|
||||
return
|
||||
|
||||
# This runs on every command, so nothing here, including reading back what
|
||||
# the latest check stored, may ever interrupt MVT.
|
||||
try:
|
||||
distributions = installed_plugin_distributions()
|
||||
|
||||
# There is nothing to check when MVT was not extended with any plugin.
|
||||
if not distributions:
|
||||
return
|
||||
|
||||
plugin_updates = PluginUpdates()
|
||||
|
||||
# We only perform this check at a fixed frequency, in order to not
|
||||
# overburden the user (and the plugin repositories) with too many
|
||||
# lookups. In between checks we print the findings of the latest one,
|
||||
# leaving out those which no longer apply to what is installed.
|
||||
should_check, _ = plugin_updates.should_check()
|
||||
if not should_check:
|
||||
_print_plugin_updates(plugin_updates.current_findings(distributions))
|
||||
return
|
||||
|
||||
findings = plugin_updates.check()
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
rich_print(
|
||||
"\t[bold]Note: Could not check for plugin updates.[/bold] "
|
||||
"You may be working offline. Please update your MVT plugins regularly."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Error encountered when trying to check MVT plugin updates: %s", e)
|
||||
else:
|
||||
_print_plugin_updates(findings)
|
||||
|
||||
|
||||
def check_updates(
|
||||
disable_version_check: bool = False, disable_indicator_check: bool = False
|
||||
) -> None:
|
||||
@@ -19,77 +134,25 @@ def check_updates(
|
||||
|
||||
# First we check for MVT version updates.
|
||||
if not disable_version_check:
|
||||
try:
|
||||
mvt_updates = MVTUpdates()
|
||||
latest_version = mvt_updates.check()
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
rich_print(
|
||||
"\t\t[bold]Note: Could not check for MVT updates.[/bold] "
|
||||
"You may be working offline. Please update MVT regularly."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
"Error encountered when trying to check latest MVT version: %s", e
|
||||
)
|
||||
else:
|
||||
if latest_version:
|
||||
rich_print(
|
||||
f"\t\t[bold]Version {latest_version} is available! "
|
||||
"Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]"
|
||||
)
|
||||
_check_version_updates(log)
|
||||
|
||||
# Then we check for indicators files updates.
|
||||
if not disable_indicator_check:
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
_check_indicator_updates(log)
|
||||
|
||||
# Before proceeding, we check if we have downloaded an indicators index.
|
||||
# If not, there's no point in proceeding with the updates check.
|
||||
if ioc_updates.get_latest_update() == 0:
|
||||
rich_print(
|
||||
"\t\t[bold]You have not yet downloaded any indicators, check "
|
||||
"the `download-iocs` command![/bold]"
|
||||
)
|
||||
return
|
||||
|
||||
# We only perform this check at a fixed frequency, in order to not
|
||||
# overburden the user with too many lookups if the command is being run
|
||||
# multiple times.
|
||||
should_check, hours = ioc_updates.should_check()
|
||||
if not should_check:
|
||||
rich_print(
|
||||
f"\t\tIndicators updates checked recently, next automatic check "
|
||||
f"in {int(hours)} hours"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
ioc_to_update = ioc_updates.check()
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
rich_print(
|
||||
"\t\t[bold]Note: Could not check for indicator updates.[/bold] "
|
||||
"You may be working offline. Please update MVT indicators regularly."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
"Error encountered when trying to check latest MVT indicators: %s", e
|
||||
)
|
||||
else:
|
||||
if ioc_to_update:
|
||||
rich_print(
|
||||
"\t\t[bold]There are updates to your indicators files! "
|
||||
"Run the `download-iocs` command to update![/bold]"
|
||||
)
|
||||
else:
|
||||
rich_print("\t\tYour indicators files seem to be up to date.")
|
||||
# Finally we check for updates to the installed plugin packages. MVT never
|
||||
# installs an update itself, it only reports the command which does.
|
||||
if not disable_version_check:
|
||||
_check_plugin_updates(log)
|
||||
|
||||
|
||||
def logo(
|
||||
disable_version_check: bool = False, disable_indicator_check: bool = False
|
||||
) -> None:
|
||||
rich_print("\n")
|
||||
rich_print("\t[bold]MVT[/bold] - Mobile Verification Toolkit")
|
||||
rich_print("\t\thttps://mvt.re")
|
||||
rich_print(f"\t\tVersion: {MVT_VERSION}")
|
||||
rich_print("\t[bold]MVT - Mobile Verification Toolkit[/bold]\n")
|
||||
rich_print("\thttps://mvt.re")
|
||||
rich_print(f"\tVersion: {MVT_VERSION}\n")
|
||||
|
||||
check_updates(disable_version_check, disable_indicator_check)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from .module_types import (
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
ModuleTimeline,
|
||||
URLResult,
|
||||
)
|
||||
from .utils import CustomJSONEncoder, exec_or_profile
|
||||
|
||||
@@ -45,6 +46,11 @@ class MVTModule:
|
||||
slug: Optional[str] = None
|
||||
dependencies: Sequence[type["MVTModule"]] = ()
|
||||
supported_commands: Sequence[tuple[str, str]] = ()
|
||||
# A custom module can name a module class it supersedes, usually a
|
||||
# built-in one. When both are available to a command, the named module is
|
||||
# dropped from the run and this module takes its place, including in the
|
||||
# dependencies of any other module.
|
||||
replaces: Optional[type["MVTModule"]] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -82,6 +88,7 @@ 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(
|
||||
@@ -110,6 +117,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
|
||||
@@ -249,6 +273,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:
|
||||
|
||||
@@ -4,18 +4,31 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
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 .cli_plugins import CUSTOM_COMMAND_MODULE_PREFIX
|
||||
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_"
|
||||
_LOADED_FILE_DIGEST = re.compile(r"_[0-9a-f]{16}$")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -23,15 +36,109 @@ 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"_mvt_custom_module_{path.stem}_{digest}"
|
||||
return f"{_PATH_MODULE_PREFIX}{path.stem}_{digest}"
|
||||
|
||||
|
||||
def _is_builtin_logger_name(name: str) -> bool:
|
||||
return name == "mvt" or name.startswith("mvt.")
|
||||
|
||||
|
||||
def _loaded_file_stem(name: str, prefix: str) -> str:
|
||||
"""Recover a loaded file's name from the import name MVT gave it."""
|
||||
return _LOADED_FILE_DIGEST.sub("", name[len(prefix) :])
|
||||
|
||||
|
||||
def _external_logger_name(name: str) -> str:
|
||||
"""Return the "mvt.ext" logger name external code logs under."""
|
||||
top_level, separator, rest = name.partition(".")
|
||||
if top_level.startswith(PLUGIN_PACKAGE_PREFIX) and len(top_level) > len(
|
||||
PLUGIN_PACKAGE_PREFIX
|
||||
):
|
||||
name = top_level[len(PLUGIN_PACKAGE_PREFIX) :] + separator + rest
|
||||
return f"{EXTERNAL_LOGGER_NAMESPACE}.{name}"
|
||||
|
||||
|
||||
def get_module_logger(module_class: type[MVTModule]) -> logging.Logger:
|
||||
"""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 _is_builtin_logger_name(name):
|
||||
return logging.getLogger(name)
|
||||
|
||||
if name.startswith(_PATH_MODULE_PREFIX):
|
||||
file_name = Path(get_module_origin(module_class).name).stem
|
||||
return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{file_name}")
|
||||
|
||||
return logging.getLogger(_external_logger_name(name))
|
||||
|
||||
|
||||
def get_plugin_logger(name: str) -> logging.Logger:
|
||||
"""Return a general logger for use in custom MVT plugins.
|
||||
|
||||
Call it with ``__name__``. The logger sits under "mvt.ext". That is
|
||||
where get_module_logger() puts module classes. A file loaded with
|
||||
--load-module or --load-command is named after the file.
|
||||
"""
|
||||
if _is_builtin_logger_name(name):
|
||||
return logging.getLogger(name)
|
||||
|
||||
# A file loaded with --load-module or --load-command is imported under a
|
||||
# mangled name. Log it under the file it came from. get_module_logger()
|
||||
# does the same for the module classes such a file defines.
|
||||
for prefix in (_PATH_MODULE_PREFIX, CUSTOM_COMMAND_MODULE_PREFIX):
|
||||
if name.startswith(prefix):
|
||||
stem = _loaded_file_stem(name, prefix)
|
||||
return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{stem}")
|
||||
|
||||
return logging.getLogger(_external_logger_name(name))
|
||||
|
||||
|
||||
def _iter_module_files(path: Path) -> Iterable[Path]:
|
||||
if path.is_file():
|
||||
if path.suffix != ".py":
|
||||
raise CustomModuleLoadError(f"Custom module file is not a Python file: {path}")
|
||||
raise CustomModuleLoadError(
|
||||
f"Custom module file is not a Python file: {path}"
|
||||
)
|
||||
yield path
|
||||
return
|
||||
|
||||
@@ -59,7 +166,9 @@ def _load_python_file(path: Path) -> ModuleType:
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as exc:
|
||||
raise CustomModuleLoadError(f"Unable to import custom module {path}: {exc}") from exc
|
||||
raise CustomModuleLoadError(
|
||||
f"Unable to import custom module {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
return module
|
||||
|
||||
@@ -84,17 +193,171 @@ def load_custom_modules_from_path(path: str) -> list[type[MVTModule]]:
|
||||
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_direct_url(dist: importlib.metadata.Distribution) -> Optional[dict]:
|
||||
"""Return the PEP 610 direct URL metadata of a distribution, if recorded.
|
||||
|
||||
Packages installed from an index have no direct URL metadata, while
|
||||
packages installed directly from a repository or from a local folder
|
||||
record where they were installed from in ``direct_url.json``.
|
||||
"""
|
||||
try:
|
||||
direct_url_text = dist.read_text("direct_url.json")
|
||||
if not direct_url_text:
|
||||
return None
|
||||
direct_url = json.loads(direct_url_text)
|
||||
return direct_url if isinstance(direct_url, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _distribution_commit(dist: importlib.metadata.Distribution) -> Optional[str]:
|
||||
"""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).
|
||||
"""
|
||||
vcs_info = (distribution_direct_url(dist) or {}).get("vcs_info")
|
||||
if not isinstance(vcs_info, dict):
|
||||
return None
|
||||
|
||||
commit = vcs_info.get("commit_id")
|
||||
return commit if isinstance(commit, str) else None
|
||||
|
||||
|
||||
def _entry_point_origin(entry_point: importlib.metadata.EntryPoint) -> ModuleOrigin:
|
||||
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)
|
||||
@@ -105,10 +368,17 @@ def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTM
|
||||
|
||||
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):
|
||||
source = Path(inspect.getfile(module_class)).resolve()
|
||||
key = (str(source), module_class.__qualname__)
|
||||
key = _module_key(module_class)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
@@ -131,4 +401,14 @@ def module_supports_command(
|
||||
)
|
||||
return False
|
||||
|
||||
return (platform, command) in {tuple(entry) for entry in supported_commands}
|
||||
pairs = {tuple(entry) for entry in supported_commands}
|
||||
if (platform, command) in pairs:
|
||||
return True
|
||||
|
||||
# A module which implements check_indicators() is re-checked by check-iocs
|
||||
# for its platform. It does not need to declare the check-iocs pair.
|
||||
return (
|
||||
command == "check-iocs"
|
||||
and platform in {entry[0] for entry in pairs if entry}
|
||||
and module_class.check_indicators is not MVTModule.check_indicators
|
||||
)
|
||||
@@ -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,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
|
||||
+416
-3
@@ -3,8 +3,13 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
@@ -12,14 +17,35 @@ import requests
|
||||
import yaml
|
||||
from packaging import version
|
||||
|
||||
from .cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
)
|
||||
from .config import settings
|
||||
from .indicators import MVT_DATA_FOLDER, MVT_INDICATORS_FOLDER
|
||||
from .module_loader import MODULES_ENTRY_POINT_GROUP, distribution_direct_url
|
||||
from .version import MVT_VERSION
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# In hours.
|
||||
INDICATORS_CHECK_FREQUENCY = 12
|
||||
PLUGINS_CHECK_FREQUENCY = 12
|
||||
|
||||
# The entry-point groups a package can use to extend MVT.
|
||||
PLUGIN_ENTRY_POINT_GROUPS = (
|
||||
MODULES_ENTRY_POINT_GROUP,
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
)
|
||||
SHORT_COMMIT_LENGTH = 8
|
||||
# The keys every cached finding has to carry to be printed.
|
||||
FINDING_KEYS = ("name", "installed", "latest", "upgrade_command")
|
||||
# Options which stop ssh from waiting for an answer nobody is there to give.
|
||||
_SSH_BATCH_OPTIONS = ("-o", "BatchMode=yes", "-o", "ConnectTimeout=10")
|
||||
_COMMIT_PATTERN = re.compile(r"\A[0-9a-f]{7,40}\Z")
|
||||
|
||||
|
||||
class MVTUpdates:
|
||||
@@ -38,6 +64,395 @@ class MVTUpdates:
|
||||
return ""
|
||||
|
||||
|
||||
def installed_plugin_distributions() -> list[importlib.metadata.Distribution]:
|
||||
"""Return the installed distributions which extend MVT.
|
||||
|
||||
A plugin package is any distribution registering at least one entry point
|
||||
in the module or CLI command groups. Distributions are returned once each,
|
||||
sorted by name. MVT itself is not a plugin and is never returned.
|
||||
"""
|
||||
distributions: dict[str, importlib.metadata.Distribution] = {}
|
||||
|
||||
for group in PLUGIN_ENTRY_POINT_GROUPS:
|
||||
try:
|
||||
entry_points = importlib.metadata.entry_points(group=group)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"Unable to discover installed plugin packages in entry-point "
|
||||
"group %s: %s",
|
||||
group,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
for entry_point in entry_points:
|
||||
# Manually constructed entry points have no associated distribution.
|
||||
dist = getattr(entry_point, "dist", None)
|
||||
if dist is None:
|
||||
continue
|
||||
try:
|
||||
name = dist.name
|
||||
except Exception:
|
||||
continue
|
||||
if not name or name == "mvt":
|
||||
continue
|
||||
distributions.setdefault(name, dist)
|
||||
|
||||
return [distributions[name] for name in sorted(distributions)]
|
||||
|
||||
|
||||
def _is_usable_finding(finding: object) -> bool:
|
||||
"""Check that a cached finding carries everything needed to print it."""
|
||||
if not isinstance(finding, dict):
|
||||
return False
|
||||
|
||||
return all(
|
||||
isinstance(finding.get(key), str) and finding.get(key) for key in FINDING_KEYS
|
||||
)
|
||||
|
||||
|
||||
def _installed_revision(
|
||||
dist: importlib.metadata.Distribution, origin: object
|
||||
) -> Optional[str]:
|
||||
"""Return what a plugin's installed revision is right now.
|
||||
|
||||
This is the value a finding recorded as installed when it was made, so a
|
||||
finding can be compared against the current state of the installation
|
||||
without looking anything up remotely.
|
||||
"""
|
||||
try:
|
||||
if origin != "git":
|
||||
return dist.version
|
||||
|
||||
vcs_info = (distribution_direct_url(dist) or {}).get("vcs_info")
|
||||
if not isinstance(vcs_info, dict):
|
||||
return None
|
||||
|
||||
commit = vcs_info.get("commit_id") or ""
|
||||
return commit[:SHORT_COMMIT_LENGTH] or None
|
||||
except Exception as e:
|
||||
log.debug("Failed to read the installed revision of a plugin: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _batch_mode_ssh_command(ssh_command: str) -> str:
|
||||
"""Return an ssh command line which cannot stop to ask a question.
|
||||
|
||||
ssh keeps the first value it is given for a keyword, so the batch mode
|
||||
options are inserted right after the ssh program, ahead of whatever the
|
||||
analyst configured. Their remaining options, such as which key to use,
|
||||
still apply.
|
||||
"""
|
||||
try:
|
||||
arguments = shlex.split(ssh_command.strip())
|
||||
except ValueError:
|
||||
arguments = []
|
||||
|
||||
if not arguments:
|
||||
arguments = ["ssh"]
|
||||
|
||||
return shlex.join([arguments[0], *_SSH_BATCH_OPTIONS, *arguments[1:]])
|
||||
|
||||
|
||||
def _revision_pins_commit(revision: str, commit: str) -> bool:
|
||||
"""Check whether a requested revision pins the installed commit."""
|
||||
candidate = revision.lower()
|
||||
if not _COMMIT_PATTERN.match(candidate):
|
||||
return False
|
||||
|
||||
return commit.lower().startswith(candidate)
|
||||
|
||||
|
||||
class PluginUpdates:
|
||||
"""Check for updates to the installed MVT plugin packages.
|
||||
|
||||
MVT never installs or upgrades a plugin package itself. It only reports
|
||||
the command which upgrades a plugin, leaving the analyst to decide when to
|
||||
run it.
|
||||
"""
|
||||
|
||||
@property
|
||||
def latest_check_path(self) -> str:
|
||||
return os.path.join(MVT_DATA_FOLDER, "latest_plugins_check")
|
||||
|
||||
@property
|
||||
def findings_path(self) -> str:
|
||||
return os.path.join(MVT_DATA_FOLDER, "plugin_updates.json")
|
||||
|
||||
def _create_data_folder(self) -> None:
|
||||
if not os.path.exists(MVT_DATA_FOLDER):
|
||||
os.makedirs(MVT_DATA_FOLDER)
|
||||
|
||||
def get_latest_check(self) -> int:
|
||||
if not os.path.exists(self.latest_check_path):
|
||||
return 0
|
||||
|
||||
# A corrupt or truncated timestamp only means the next check happens
|
||||
# sooner. It must never stop MVT from running.
|
||||
try:
|
||||
with open(self.latest_check_path, "r", encoding="utf-8") as handle:
|
||||
data = handle.read().strip()
|
||||
if data:
|
||||
return int(data)
|
||||
except (OSError, ValueError) as e:
|
||||
log.debug("Failed to read the time of the latest plugin check: %s", e)
|
||||
|
||||
return 0
|
||||
|
||||
def set_latest_check(self) -> None:
|
||||
self._create_data_folder()
|
||||
timestamp = int(datetime.now().timestamp())
|
||||
with open(self.latest_check_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(str(timestamp))
|
||||
|
||||
def get_findings(self) -> list[dict]:
|
||||
"""
|
||||
Return the findings of the latest check, without checking again.
|
||||
Returns an empty list if no check was ever performed.
|
||||
"""
|
||||
if not os.path.exists(self.findings_path):
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(self.findings_path, "r", encoding="utf-8") as handle:
|
||||
findings = json.load(handle)
|
||||
except Exception as e:
|
||||
log.debug("Failed to read the cached plugin updates: %s", e)
|
||||
return []
|
||||
|
||||
if not isinstance(findings, list):
|
||||
return []
|
||||
|
||||
# Anything which does not look like a finding is dropped rather than
|
||||
# trusted: the cache is only a convenience.
|
||||
return [finding for finding in findings if _is_usable_finding(finding)]
|
||||
|
||||
def current_findings(
|
||||
self, distributions: Optional[list[importlib.metadata.Distribution]] = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Return the cached findings which still apply to what is installed.
|
||||
Findings about a plugin which was upgraded or removed since the latest
|
||||
check are dropped, so an update is never reported twice.
|
||||
"""
|
||||
if distributions is None:
|
||||
distributions = installed_plugin_distributions()
|
||||
|
||||
installed = {}
|
||||
for dist in distributions:
|
||||
try:
|
||||
installed[dist.name] = dist
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
current = []
|
||||
for finding in self.get_findings():
|
||||
plugin = installed.get(finding["name"])
|
||||
if plugin is None:
|
||||
continue
|
||||
if (
|
||||
_installed_revision(plugin, finding.get("origin"))
|
||||
!= finding["installed"]
|
||||
):
|
||||
continue
|
||||
current.append(finding)
|
||||
|
||||
return current
|
||||
|
||||
def set_findings(self, findings: list[dict]) -> None:
|
||||
self._create_data_folder()
|
||||
with open(self.findings_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(findings, handle)
|
||||
|
||||
def should_check(self) -> Tuple[bool, int]:
|
||||
"""
|
||||
Compare time of the latest plugins check with current time.
|
||||
Returns bool and number of hours since the last check.
|
||||
"""
|
||||
now = datetime.now()
|
||||
latest_check_ts = self.get_latest_check()
|
||||
latest_check_dt = datetime.fromtimestamp(latest_check_ts)
|
||||
|
||||
diff = now - latest_check_dt
|
||||
diff_hours = divmod(diff.total_seconds(), 3600)[0]
|
||||
|
||||
if diff_hours >= PLUGINS_CHECK_FREQUENCY:
|
||||
return True, 0
|
||||
|
||||
return False, int(PLUGINS_CHECK_FREQUENCY - diff_hours)
|
||||
|
||||
def _check_index_plugin(self, name: str, installed: str) -> Optional[dict]:
|
||||
"""Check a plugin installed from a package index for a newer release."""
|
||||
url = f"https://pypi.org/pypi/{name}/json"
|
||||
try:
|
||||
res = requests.get(url, timeout=settings.NETWORK_TIMEOUT)
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.debug("Failed to check for updates to plugin %s: %s", name, e)
|
||||
return None
|
||||
|
||||
# Plugins which were never published to a public index are expected,
|
||||
# and there is nothing to compare their version against.
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
|
||||
if res.status_code != 200:
|
||||
log.debug(
|
||||
"Failed to check for updates to plugin %s (error %d)",
|
||||
name,
|
||||
res.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
latest = res.json().get("info", {}).get("version", "")
|
||||
if not latest or version.parse(latest) <= version.parse(installed):
|
||||
return None
|
||||
except Exception as e:
|
||||
log.debug("Failed to compare the versions of plugin %s: %s", name, e)
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"installed": installed,
|
||||
"latest": latest,
|
||||
"origin": "pypi",
|
||||
# The name comes from package metadata, so the command MVT
|
||||
# suggests is quoted rather than assumed to be shell-safe.
|
||||
"upgrade_command": f"pip install -U {shlex.quote(name)}",
|
||||
}
|
||||
|
||||
def _git_ls_remote(self, url: str, revision: str) -> list[Tuple[str, str]]:
|
||||
"""Return the remote references matching a revision, if git allows it."""
|
||||
# Neither value is trusted: they are read from the metadata of an
|
||||
# installed package and must not turn into git options.
|
||||
if url.startswith("-") or revision.startswith("-"):
|
||||
log.debug("Skipping the update check for the invalid repository %s", url)
|
||||
return []
|
||||
|
||||
environment = dict(os.environ)
|
||||
# Never prompt the analyst for repository credentials. git handles its
|
||||
# own prompts, while ssh reads the terminal directly and only batch
|
||||
# mode makes it fail instead of asking.
|
||||
environment["GIT_TERMINAL_PROMPT"] = "0"
|
||||
environment["GIT_SSH_COMMAND"] = _batch_mode_ssh_command(
|
||||
environment.get("GIT_SSH_COMMAND", "")
|
||||
)
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
["git", "ls-remote", url, revision],
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
text=True,
|
||||
env=environment,
|
||||
timeout=settings.NETWORK_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
log.debug("Could not find git, skipping the update check for %s", url)
|
||||
return []
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
log.debug("Failed to query the repository %s: %s", url, e)
|
||||
return []
|
||||
|
||||
if process.returncode != 0:
|
||||
log.debug(
|
||||
"Failed to query the repository %s (error %d): %s",
|
||||
url,
|
||||
process.returncode,
|
||||
(process.stderr or "").strip(),
|
||||
)
|
||||
return []
|
||||
|
||||
references = []
|
||||
for line in (process.stdout or "").splitlines():
|
||||
commit, _, reference = line.partition("\t")
|
||||
if commit.strip() and reference.strip():
|
||||
references.append((commit.strip(), reference.strip()))
|
||||
|
||||
return references
|
||||
|
||||
def _check_repository_plugin(
|
||||
self, name: str, direct_url: dict, vcs_info: dict
|
||||
) -> Optional[dict]:
|
||||
"""Check a plugin installed from a repository for a newer commit."""
|
||||
url = direct_url.get("url") or ""
|
||||
installed = vcs_info.get("commit_id") or ""
|
||||
revision = vcs_info.get("requested_revision") or ""
|
||||
if not url or not installed:
|
||||
return None
|
||||
|
||||
# A plugin installed from a commit is pinned and never goes out of
|
||||
# date, no matter what the branch it came from does next.
|
||||
if revision and _revision_pins_commit(revision, installed):
|
||||
return None
|
||||
|
||||
latest = ""
|
||||
wanted_reference = f"refs/heads/{revision}" if revision else "HEAD"
|
||||
for commit, reference in self._git_ls_remote(url, revision or "HEAD"):
|
||||
# Tags are pinned installs too.
|
||||
if reference.startswith("refs/tags/"):
|
||||
return None
|
||||
if reference == wanted_reference:
|
||||
latest = commit
|
||||
|
||||
if not latest or latest == installed:
|
||||
return None
|
||||
|
||||
requirement = f"{name} @ git+{url}"
|
||||
if revision:
|
||||
requirement += f"@{revision}"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"installed": installed[:SHORT_COMMIT_LENGTH],
|
||||
"latest": latest[:SHORT_COMMIT_LENGTH],
|
||||
"origin": "git",
|
||||
# A repository URL and a branch name can both hold characters a
|
||||
# shell would act on, so the requirement is quoted for the shell
|
||||
# the analyst is going to paste the command into.
|
||||
"upgrade_command": f"pip install -U {shlex.quote(requirement)}",
|
||||
}
|
||||
|
||||
def _check_distribution(
|
||||
self, dist: importlib.metadata.Distribution
|
||||
) -> Optional[dict]:
|
||||
try:
|
||||
name = dist.name
|
||||
installed = dist.version
|
||||
except Exception as e:
|
||||
log.debug("Failed to read the metadata of an installed plugin: %s", e)
|
||||
return None
|
||||
|
||||
direct_url = distribution_direct_url(dist)
|
||||
if direct_url is None:
|
||||
return self._check_index_plugin(name, installed)
|
||||
|
||||
vcs_info = direct_url.get("vcs_info")
|
||||
if isinstance(vcs_info, dict):
|
||||
return self._check_repository_plugin(name, direct_url, vcs_info)
|
||||
|
||||
# Plugins installed from a local folder, including editable installs,
|
||||
# are maintained by the analyst and have nothing to check against.
|
||||
return None
|
||||
|
||||
def check(self) -> list[dict]:
|
||||
"""
|
||||
Check every installed plugin package for an available update.
|
||||
Returns one entry per plugin which can be upgraded.
|
||||
"""
|
||||
findings = []
|
||||
for dist in installed_plugin_distributions():
|
||||
finding = self._check_distribution(dist)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
self.set_findings(findings)
|
||||
self.set_latest_check()
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
class IndicatorsUpdates:
|
||||
def __init__(self) -> None:
|
||||
self.github_raw_url = "https://raw.githubusercontent.com/{}/{}/{}/{}"
|
||||
@@ -180,9 +595,7 @@ class IndicatorsUpdates:
|
||||
def _get_remote_file_latest_commit(
|
||||
self, owner: str, repo: str, branch: str, path: str
|
||||
) -> int:
|
||||
file_commit_url = (
|
||||
f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}"
|
||||
)
|
||||
file_commit_url = f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}"
|
||||
try:
|
||||
res = requests.get(file_commit_url, timeout=5)
|
||||
except requests.exceptions.RequestException as e:
|
||||
|
||||
+22
-6
@@ -14,7 +14,6 @@ from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Iterator, Union
|
||||
|
||||
from .log import MVTLogHandler
|
||||
from mvt.common.config import settings
|
||||
|
||||
|
||||
class CustomJSONEncoder(json.JSONEncoder):
|
||||
@@ -239,6 +238,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:
|
||||
@@ -249,16 +255,26 @@ def init_logging(verbose: bool = False):
|
||||
|
||||
|
||||
def set_verbose_logging(verbose: bool = False):
|
||||
"""Raise or lower the verbosity of MVT's console output.
|
||||
|
||||
Only MVT's own console handler is adjusted, wherever it sits in the list.
|
||||
The file handler a command attaches to its output folder keeps recording
|
||||
everything, so the command.log of a run does not depend on how the run was
|
||||
invoked, and a handler attached to the "mvt" logger by anything else is
|
||||
left alone.
|
||||
"""
|
||||
log = logging.getLogger("mvt")
|
||||
handler = log.handlers[0]
|
||||
if verbose:
|
||||
handler.setLevel(logging.DEBUG)
|
||||
else:
|
||||
handler.setLevel(logging.INFO)
|
||||
for handler in log.handlers:
|
||||
if isinstance(handler, MVTLogHandler):
|
||||
handler.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
|
||||
|
||||
def exec_or_profile(module, globals, locals):
|
||||
"""Hook for profiling MVT modules"""
|
||||
# Imported here so that the CLI modules, which import this one at start-up,
|
||||
# do not load the settings (and pydantic) before a command runs.
|
||||
from .config import settings
|
||||
|
||||
if settings.PROFILE:
|
||||
cProfile.runctx(module, globals, locals)
|
||||
else:
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
MVT_VERSION = "2026.6.29"
|
||||
from importlib.metadata import version
|
||||
|
||||
MVT_VERSION = version("mvt")
|
||||
@@ -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/
|
||||
|
||||
from .cli import cli
|
||||
from .cli import cli, main
|
||||
+86
-67
@@ -8,16 +8,13 @@ import logging
|
||||
import os
|
||||
|
||||
import click
|
||||
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.cli_plugins import (
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
load_cli_commands_option,
|
||||
register_cli_plugins,
|
||||
)
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.options import MutuallyExclusiveOption
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
from mvt.common.utils import (
|
||||
generate_hashes_from_path,
|
||||
init_logging,
|
||||
@@ -27,6 +24,7 @@ from mvt.common.help import (
|
||||
HELP_MSG_VERSION,
|
||||
HELP_MSG_DECRYPT_BACKUP,
|
||||
HELP_MSG_BACKUP_DESTINATION,
|
||||
HELP_MSG_DECRYPT_JOBS,
|
||||
HELP_MSG_IOS_BACKUP_PASSWORD,
|
||||
HELP_MSG_BACKUP_KEYFILE,
|
||||
HELP_MSG_HASHES,
|
||||
@@ -38,6 +36,7 @@ from mvt.common.help import (
|
||||
HELP_MSG_LOAD_MODULE,
|
||||
HELP_MSG_MODULE,
|
||||
HELP_MSG_VERBOSE,
|
||||
HELP_MSG_VERBOSE_COMMAND,
|
||||
HELP_MSG_CHECK_FS,
|
||||
HELP_MSG_CHECK_IOCS,
|
||||
HELP_MSG_STIX2,
|
||||
@@ -45,17 +44,17 @@ from mvt.common.help import (
|
||||
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
|
||||
from .modules.mixed import MIXED_MODULES
|
||||
from .decrypt_config import (
|
||||
DEFAULT_DECRYPT_WORKERS,
|
||||
MAX_DECRYPT_WORKERS,
|
||||
)
|
||||
|
||||
# The commands import what they run only when they are invoked. This module is
|
||||
# imported at every start of mvt-ios, including by shell completion on every
|
||||
# keystroke, so importing it must do no more than build the command tree: the
|
||||
# forensic modules, the backup decryption and the update checks stay out of it.
|
||||
|
||||
init_logging()
|
||||
log = logging.getLogger("mvt")
|
||||
@@ -75,7 +74,14 @@ def _get_disable_flags(ctx):
|
||||
)
|
||||
|
||||
|
||||
def _get_verbose(ctx):
|
||||
"""Return whether --verbose was passed to the CLI itself."""
|
||||
return bool(ctx.obj and ctx.obj.get("verbose", False))
|
||||
|
||||
|
||||
def _load_custom_modules(load_module):
|
||||
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
|
||||
|
||||
try:
|
||||
return load_custom_modules(load_module)
|
||||
except CustomModuleLoadError as exc:
|
||||
@@ -86,6 +92,7 @@ def _load_custom_modules(load_module):
|
||||
# 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
|
||||
)
|
||||
@@ -94,60 +101,31 @@ def _load_custom_modules(load_module):
|
||||
is_flag=True,
|
||||
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
|
||||
@click.pass_context
|
||||
def cli(ctx, disable_update_check, disable_indicator_update_check):
|
||||
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["disable_version_check"] = disable_update_check
|
||||
ctx.obj["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,
|
||||
)
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
from mvt.common.logo import logo
|
||||
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: version
|
||||
# ==============================================================================
|
||||
@cli.command("version", help=HELP_MSG_VERSION)
|
||||
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
|
||||
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
|
||||
# ==============================================================================
|
||||
@@ -155,6 +133,13 @@ def completion(ctx, shell, install):
|
||||
"decrypt-backup", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_DECRYPT_BACKUP
|
||||
)
|
||||
@click.option("--destination", "-d", required=True, help=HELP_MSG_BACKUP_DESTINATION)
|
||||
@click.option(
|
||||
"--jobs",
|
||||
type=click.IntRange(1, MAX_DECRYPT_WORKERS),
|
||||
default=DEFAULT_DECRYPT_WORKERS,
|
||||
show_default=True,
|
||||
help=HELP_MSG_DECRYPT_JOBS,
|
||||
)
|
||||
@click.option(
|
||||
"--password",
|
||||
"-p",
|
||||
@@ -173,8 +158,10 @@ def completion(ctx, shell, install):
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
|
||||
backup = DecryptBackup(backup_path, destination)
|
||||
def decrypt_backup(ctx, destination, jobs, password, key_file, hashes, backup_path):
|
||||
from .decrypt import DecryptBackup
|
||||
|
||||
backup = DecryptBackup(backup_path, destination, max_workers=jobs)
|
||||
|
||||
if key_file:
|
||||
if MVT_IOS_BACKUP_PASSWORD in os.environ:
|
||||
@@ -237,6 +224,8 @@ def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
|
||||
)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
def extract_key(password, key_file, backup_path):
|
||||
from .decrypt import DecryptBackup
|
||||
|
||||
backup = DecryptBackup(backup_path)
|
||||
|
||||
if password:
|
||||
@@ -289,7 +278,7 @@ def extract_key(password, key_file, backup_path):
|
||||
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.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_backup(
|
||||
@@ -304,7 +293,9 @@ def check_backup(
|
||||
verbose,
|
||||
backup_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
from .cmd_check_backup import CmdIOSCheckBackup
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
module_options = {"fast_mode": fast}
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
@@ -358,7 +349,7 @@ def check_backup(
|
||||
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.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("DUMP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_fs(
|
||||
@@ -373,7 +364,9 @@ def check_fs(
|
||||
verbose,
|
||||
dump_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
from .cmd_check_fs import CmdIOSCheckFS
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
module_options = {"fast_mode": fast}
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
@@ -427,7 +420,7 @@ def check_fs(
|
||||
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.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_sysdiagnose(
|
||||
@@ -441,7 +434,9 @@ def check_sysdiagnose(
|
||||
verbose,
|
||||
sysdiagnose_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdIOSCheckSysdiagnose(
|
||||
target_path=sysdiagnose_path,
|
||||
@@ -495,6 +490,10 @@ def check_sysdiagnose(
|
||||
@click.argument("FOLDER", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
from mvt.common.cmd_check_iocs import CmdCheckIOCS
|
||||
|
||||
from .command_modules import IOS_CHECK_IOCS_MODULES
|
||||
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdCheckIOCS(
|
||||
target_path=folder,
|
||||
@@ -505,7 +504,7 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
custom_modules=custom_modules,
|
||||
platform="ios",
|
||||
)
|
||||
cmd.modules = BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
cmd.modules = IOS_CHECK_IOCS_MODULES
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
@@ -521,5 +520,25 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
# ==============================================================================
|
||||
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
|
||||
def download_iocs():
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
ioc_updates.update()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Entry point of the mvt-ios console script
|
||||
# ==============================================================================
|
||||
def main() -> None:
|
||||
"""Register the external commands and run the mvt-ios CLI.
|
||||
|
||||
External commands are registered here rather than when this module is
|
||||
imported, so that importing MVT never runs third-party code and a plugin
|
||||
importing from MVT cannot re-enter a module that is still initializing.
|
||||
"""
|
||||
register_cli_plugins(
|
||||
cli,
|
||||
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
cli()
|
||||
@@ -6,8 +6,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Optional
|
||||
|
||||
from mvt.common.command import Command
|
||||
@@ -54,6 +56,8 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
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]:
|
||||
@@ -91,21 +95,68 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
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")
|
||||
for member in self.sysdiagnose_archive:
|
||||
self.sysdiagnose_files.append(member.name)
|
||||
if member.isfile() and member.name.endswith(".ips"):
|
||||
archive_handle = self.sysdiagnose_archive.extractfile(member)
|
||||
if archive_handle is not None:
|
||||
with archive_handle:
|
||||
self._add_ips_file(member.name, archive_handle.read())
|
||||
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.sysdiagnose_archive is None:
|
||||
raise RuntimeError("Sysdiagnose archive has not been initialized")
|
||||
module.from_sysdiagnose_tar(
|
||||
self.sysdiagnose_archive, self.sysdiagnose_files
|
||||
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:
|
||||
@@ -117,3 +168,6 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
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
|
||||
@@ -0,0 +1,22 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Module lists an mvt-ios command composes from more than one family.
|
||||
|
||||
Commands whose modules are one family read that family directly. check-iocs
|
||||
re-checks stored results, so it has to know every module that could have
|
||||
written one, and both the CLI and any other code needing that answer share
|
||||
the list from here rather than each concatenating their own.
|
||||
"""
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
from .modules.fs import FS_MODULES
|
||||
from .modules.mixed import MIXED_MODULES
|
||||
|
||||
IOS_CHECK_IOCS_MODULES: list[type[MVTModule]] = (
|
||||
BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
+294
-76
@@ -6,18 +6,179 @@
|
||||
import binascii
|
||||
import glob
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import os.path
|
||||
import plistlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from concurrent.futures import (
|
||||
ALL_COMPLETED,
|
||||
FIRST_COMPLETED,
|
||||
Future,
|
||||
ThreadPoolExecutor,
|
||||
wait,
|
||||
)
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from iOSbackup import iOSbackup
|
||||
from iphone_backup_decrypt import EncryptedBackup
|
||||
from iphone_backup_decrypt import google_iphone_dataprotection
|
||||
from iphone_backup_decrypt.utils import FilePlist
|
||||
|
||||
from .decrypt_config import DEFAULT_DECRYPT_WORKERS, MAX_DECRYPT_WORKERS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Import pbkdf2_hmac from the same source iphone_backup_decrypt uses internally,
|
||||
# so our key derivation is consistent with theirs.
|
||||
try:
|
||||
from fastpbkdf2 import pbkdf2_hmac
|
||||
except ImportError:
|
||||
import Crypto.Hash.SHA1
|
||||
import Crypto.Hash.SHA256
|
||||
import Crypto.Protocol.KDF
|
||||
|
||||
_HASH_FNS = {"sha1": Crypto.Hash.SHA1, "sha256": Crypto.Hash.SHA256}
|
||||
|
||||
def pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None):
|
||||
return Crypto.Protocol.KDF.PBKDF2(
|
||||
password, salt, dklen, iterations, hmac_hash_module=_HASH_FNS[hash_name]
|
||||
)
|
||||
|
||||
|
||||
class MVTEncryptedBackup(EncryptedBackup):
|
||||
"""Extends EncryptedBackup with derived key export/import.
|
||||
|
||||
NOTE: This subclass relies on internal APIs of iphone_backup_decrypt
|
||||
(specifically _read_and_unlock_keybag, _keybag, and the Keybag class
|
||||
internals). Pinned to iphone_backup_decrypt==0.9.0.
|
||||
"""
|
||||
|
||||
def __init__(self, *, backup_directory, passphrase=None, derived_key=None):
|
||||
if passphrase:
|
||||
super().__init__(backup_directory=backup_directory, passphrase=passphrase)
|
||||
self._derived_key = None # Will be set after keybag unlock
|
||||
elif derived_key:
|
||||
self._init_without_passphrase(backup_directory, derived_key)
|
||||
else:
|
||||
raise ValueError("Either passphrase or derived_key must be provided")
|
||||
|
||||
def _init_without_passphrase(self, backup_directory, derived_key):
|
||||
"""Replicate parent __init__ state without requiring a passphrase."""
|
||||
self.decrypted = False
|
||||
self._backup_directory = os.path.expandvars(backup_directory)
|
||||
self._passphrase = None
|
||||
self._manifest_plist_path = os.path.join(
|
||||
self._backup_directory, "Manifest.plist"
|
||||
)
|
||||
self._manifest_plist = None
|
||||
self._manifest_db_path = os.path.join(self._backup_directory, "Manifest.db")
|
||||
self._keybag = None
|
||||
self._unlocked = False
|
||||
self._temporary_folder = tempfile.mkdtemp()
|
||||
self._temp_decrypted_manifest_db_path = os.path.join(
|
||||
self._temporary_folder, "Manifest.db"
|
||||
)
|
||||
self._temp_manifest_db_conn = None
|
||||
self._derived_key = derived_key # 32 raw bytes
|
||||
|
||||
def _read_and_unlock_keybag(self):
|
||||
"""Override to capture derived key on password unlock, or use
|
||||
a pre-derived key to skip PBKDF2."""
|
||||
if self._unlocked:
|
||||
return self._unlocked
|
||||
|
||||
with open(self._manifest_plist_path, "rb") as infile:
|
||||
self._manifest_plist = plistlib.load(infile)
|
||||
self._keybag = google_iphone_dataprotection.Keybag(
|
||||
self._manifest_plist["BackupKeyBag"]
|
||||
)
|
||||
|
||||
if self._derived_key:
|
||||
# Skip PBKDF2, unwrap class keys directly with pre-derived key
|
||||
self._unlocked = _unlock_keybag_with_derived_key(
|
||||
self._keybag, self._derived_key
|
||||
)
|
||||
else:
|
||||
# Normal path: full PBKDF2 derivation, capturing the intermediate key
|
||||
self._unlocked, self._derived_key = _unlock_keybag_and_capture_key(
|
||||
self._keybag, self._passphrase
|
||||
)
|
||||
self._passphrase = None
|
||||
|
||||
if not self._unlocked:
|
||||
raise ValueError("Failed to decrypt keys: incorrect passphrase?")
|
||||
return True
|
||||
|
||||
def get_decryption_key(self):
|
||||
"""Return derived key as hex string (64 chars / 32 bytes)."""
|
||||
if self._derived_key is None:
|
||||
raise ValueError("No derived key available")
|
||||
return self._derived_key.hex()
|
||||
|
||||
def extract_file_by_id(self, *, file_id, file_bplist, output_filename):
|
||||
"""Extract one manifest entry without loading the whole file into memory."""
|
||||
self._read_and_unlock_keybag()
|
||||
file_plist = FilePlist(file_bplist)
|
||||
|
||||
if file_plist.encryption_key is None:
|
||||
source_filename = os.path.join(
|
||||
self._backup_directory, file_id[:2], file_id
|
||||
)
|
||||
shutil.copy2(source_filename, output_filename)
|
||||
return
|
||||
|
||||
inner_key = self._keybag.unwrapKeyForClass(
|
||||
file_plist.protection_class, file_plist.encryption_key
|
||||
)
|
||||
self._decrypt_file_to_disk(
|
||||
file_id=file_id,
|
||||
key=inner_key,
|
||||
file_plist=file_plist,
|
||||
output_filepath=output_filename,
|
||||
)
|
||||
|
||||
|
||||
def _unlock_keybag_with_derived_key(keybag, passphrase_key):
|
||||
"""Unlock keybag class keys using a pre-derived passphrase_key,
|
||||
skipping the expensive PBKDF2 rounds."""
|
||||
WRAP_PASSPHRASE = 2
|
||||
for classkey in keybag.classKeys.values():
|
||||
if b"WPKY" not in classkey:
|
||||
continue
|
||||
if classkey[b"WRAP"] & WRAP_PASSPHRASE:
|
||||
k = google_iphone_dataprotection._AESUnwrap(
|
||||
passphrase_key, classkey[b"WPKY"]
|
||||
)
|
||||
if not k:
|
||||
return False
|
||||
classkey[b"KEY"] = k
|
||||
return True
|
||||
|
||||
|
||||
def _unlock_keybag_and_capture_key(keybag, passphrase):
|
||||
"""Run full PBKDF2 key derivation and AES unwrap, returning
|
||||
(success, passphrase_key) so the derived key can be exported."""
|
||||
passphrase_round1 = pbkdf2_hmac(
|
||||
"sha256", passphrase, keybag.attrs[b"DPSL"], keybag.attrs[b"DPIC"], 32
|
||||
)
|
||||
passphrase_key = pbkdf2_hmac(
|
||||
"sha1", passphrase_round1, keybag.attrs[b"SALT"], keybag.attrs[b"ITER"], 32
|
||||
)
|
||||
WRAP_PASSPHRASE = 2
|
||||
for classkey in keybag.classKeys.values():
|
||||
if b"WPKY" not in classkey:
|
||||
continue
|
||||
if classkey[b"WRAP"] & WRAP_PASSPHRASE:
|
||||
k = google_iphone_dataprotection._AESUnwrap(
|
||||
passphrase_key, classkey[b"WPKY"]
|
||||
)
|
||||
if not k:
|
||||
return False, None
|
||||
classkey[b"KEY"] = k
|
||||
return True, passphrase_key
|
||||
|
||||
|
||||
class DecryptBackup:
|
||||
"""This class provides functions to decrypt an encrypted iTunes backup
|
||||
@@ -26,19 +187,68 @@ class DecryptBackup:
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, backup_path: str, dest_path: Optional[str] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
backup_path: str,
|
||||
dest_path: Optional[str] = None,
|
||||
max_workers: int = DEFAULT_DECRYPT_WORKERS,
|
||||
) -> None:
|
||||
"""Decrypts an encrypted iOS backup.
|
||||
:param backup_path: Path to the encrypted backup folder
|
||||
:param dest_path: Path to the folder where to store the decrypted backup
|
||||
"""
|
||||
self.backup_path = os.path.abspath(backup_path)
|
||||
self.dest_path = dest_path
|
||||
self._backup = None
|
||||
self._decryption_key = None
|
||||
if not 1 <= max_workers <= MAX_DECRYPT_WORKERS:
|
||||
raise ValueError(f"max_workers must be between 1 and {MAX_DECRYPT_WORKERS}")
|
||||
self.max_workers = max_workers
|
||||
self._backup: Optional[MVTEncryptedBackup] = None
|
||||
self._decryption_key: Optional[str] = None
|
||||
|
||||
def can_process(self) -> bool:
|
||||
return self._backup is not None
|
||||
|
||||
def _process_file(
|
||||
self,
|
||||
*,
|
||||
file_id: str,
|
||||
file_bplist: bytes,
|
||||
output_path: Path,
|
||||
relative_path: str,
|
||||
domain: str,
|
||||
) -> None:
|
||||
assert self._backup is not None
|
||||
self._backup.extract_file_by_id(
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_filename=str(output_path),
|
||||
)
|
||||
log.info(
|
||||
"Decrypted file %s [%s] to %s/%s",
|
||||
relative_path,
|
||||
domain,
|
||||
output_path.parent,
|
||||
file_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_files(
|
||||
pending: dict[Future[None], str], *, all_files: bool = False
|
||||
) -> None:
|
||||
if not pending:
|
||||
return
|
||||
|
||||
done, _ = wait(
|
||||
pending,
|
||||
return_when=ALL_COMPLETED if all_files else FIRST_COMPLETED,
|
||||
)
|
||||
for future in done:
|
||||
relative_path = pending.pop(future)
|
||||
try:
|
||||
future.result()
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt file %s: %s", relative_path, exc)
|
||||
|
||||
@staticmethod
|
||||
def is_encrypted(backup_path: str) -> bool:
|
||||
"""Query Manifest.db file to see if it's encrypted or not.
|
||||
@@ -58,21 +268,6 @@ class DecryptBackup:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _process_file(
|
||||
self, relative_path: str, domain: str, item, file_id: str, item_folder: str
|
||||
) -> None:
|
||||
assert self._backup is not None
|
||||
self._backup.getFileDecryptedCopy(
|
||||
manifestEntry=item, targetName=file_id, targetFolder=item_folder
|
||||
)
|
||||
log.info(
|
||||
"Decrypted file %s [%s] to %s/%s",
|
||||
relative_path,
|
||||
domain,
|
||||
item_folder,
|
||||
file_id,
|
||||
)
|
||||
|
||||
def process_backup(self) -> None:
|
||||
assert self._backup is not None
|
||||
assert self.dest_path is not None
|
||||
@@ -81,53 +276,71 @@ class DecryptBackup:
|
||||
os.makedirs(self.dest_path)
|
||||
|
||||
manifest_path = os.path.join(self.dest_path, "Manifest.db")
|
||||
# We extract a decrypted Manifest.db.
|
||||
self._backup.getManifestDB()
|
||||
# We store it to the destination folder.
|
||||
shutil.copy(self._backup.manifestDB, manifest_path)
|
||||
# Extract a decrypted Manifest.db to the destination folder.
|
||||
self._backup.save_manifest_file(output_filename=manifest_path)
|
||||
|
||||
pool = multiprocessing.Pool(multiprocessing.cpu_count())
|
||||
|
||||
for item in self._backup.getBackupFilesList():
|
||||
try:
|
||||
file_id = item["backupFile"]
|
||||
relative_path = item["relativePath"]
|
||||
domain = item["domain"]
|
||||
|
||||
# This may be a partial backup. Skip files from the manifest
|
||||
# which do not exist locally.
|
||||
source_file_path = os.path.join(self.backup_path, file_id[0:2], file_id)
|
||||
if not Path(source_file_path).resolve().is_relative_to(Path(self.backup_path).resolve()):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(source_file_path):
|
||||
log.debug(
|
||||
"Skipping file %s. File not found in encrypted backup directory.",
|
||||
source_file_path,
|
||||
)
|
||||
continue
|
||||
|
||||
item_folder = os.path.join(self.dest_path, file_id[0:2]) # type: ignore[arg-type]
|
||||
if not Path(os.path.join(item_folder, file_id)).resolve().is_relative_to(Path(self.dest_path).resolve()):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(item_folder):
|
||||
os.makedirs(item_folder)
|
||||
|
||||
# iOSBackup getFileDecryptedCopy() claims to read a "file"
|
||||
# parameter but the code actually is reading the "manifest" key.
|
||||
# Add manifest plist to both keys to handle this.
|
||||
item["manifest"] = item["file"]
|
||||
|
||||
pool.apply_async(
|
||||
self._process_file,
|
||||
args=(relative_path, domain, item, file_id, item_folder),
|
||||
# Iterate over all files in the backup and decrypt them,
|
||||
# preserving the XX/file_id directory structure that downstream
|
||||
# modules expect.
|
||||
backup_root = Path(self.backup_path).resolve()
|
||||
dest_root = Path(self.dest_path).resolve()
|
||||
pending: dict[Future[None], str] = {}
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
with self._backup.manifest_db_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT fileID, domain, relativePath, file FROM Files WHERE flags=1"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt file %s: %s", relative_path, exc)
|
||||
for file_id, domain, relative_path, file_bplist in cur:
|
||||
# This may be a partial backup. Skip files from the manifest
|
||||
# which do not exist locally.
|
||||
source_file_path = backup_root / file_id[:2] / file_id
|
||||
if not source_file_path.resolve().is_relative_to(backup_root):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(source_file_path):
|
||||
log.debug(
|
||||
"Skipping file %s. File not found in encrypted "
|
||||
"backup directory.",
|
||||
source_file_path,
|
||||
)
|
||||
continue
|
||||
|
||||
pool.close()
|
||||
pool.join()
|
||||
output_path = dest_root / file_id[:2] / file_id
|
||||
if not output_path.resolve().is_relative_to(dest_root):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.max_workers == 1:
|
||||
try:
|
||||
self._process_file(
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_path=output_path,
|
||||
relative_path=relative_path,
|
||||
domain=domain,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
"Failed to decrypt file %s: %s",
|
||||
relative_path,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
future = executor.submit(
|
||||
self._process_file,
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_path=output_path,
|
||||
relative_path=relative_path,
|
||||
domain=domain,
|
||||
)
|
||||
pending[future] = relative_path
|
||||
if len(pending) >= self.max_workers:
|
||||
self._wait_for_files(pending)
|
||||
|
||||
self._wait_for_files(pending, all_files=True)
|
||||
|
||||
# Copying over the root plist files as well.
|
||||
for file_name in os.listdir(self.backup_path):
|
||||
@@ -168,20 +381,23 @@ class DecryptBackup:
|
||||
return
|
||||
|
||||
try:
|
||||
self._backup = iOSbackup(
|
||||
udid=os.path.basename(self.backup_path),
|
||||
cleartextpassword=password,
|
||||
backuproot=os.path.dirname(self.backup_path),
|
||||
self._backup = MVTEncryptedBackup(
|
||||
backup_directory=self.backup_path,
|
||||
passphrase=password,
|
||||
)
|
||||
# Eagerly trigger keybag unlock so wrong-password errors
|
||||
# surface here rather than later during process_backup().
|
||||
self._backup.test_decryption()
|
||||
except Exception as exc:
|
||||
self._backup = None
|
||||
if (
|
||||
isinstance(exc, KeyError)
|
||||
and len(exc.args) > 0
|
||||
and exc.args[0] == b"KEY"
|
||||
isinstance(exc, ValueError)
|
||||
and "passphrase" in str(exc).lower()
|
||||
):
|
||||
log.critical("Failed to decrypt backup. Password is probably wrong.")
|
||||
elif (
|
||||
isinstance(exc, FileNotFoundError)
|
||||
and hasattr(exc, "filename")
|
||||
and os.path.basename(exc.filename) == "Manifest.plist"
|
||||
):
|
||||
log.critical(
|
||||
@@ -224,12 +440,14 @@ class DecryptBackup:
|
||||
|
||||
try:
|
||||
key_bytes_raw = binascii.unhexlify(key_bytes)
|
||||
self._backup = iOSbackup(
|
||||
udid=os.path.basename(self.backup_path),
|
||||
derivedkey=key_bytes_raw,
|
||||
backuproot=os.path.dirname(self.backup_path),
|
||||
self._backup = MVTEncryptedBackup(
|
||||
backup_directory=self.backup_path,
|
||||
derived_key=key_bytes_raw,
|
||||
)
|
||||
# Eagerly trigger keybag unlock so wrong-key errors surface here.
|
||||
self._backup.test_decryption()
|
||||
except Exception as exc:
|
||||
self._backup = None
|
||||
log.exception(exc)
|
||||
log.critical(
|
||||
"Failed to decrypt backup. Did you provide the correct key file?"
|
||||
@@ -240,7 +458,7 @@ class DecryptBackup:
|
||||
if not self._backup:
|
||||
return
|
||||
|
||||
self._decryption_key = self._backup.getDecryptionKey()
|
||||
self._decryption_key = self._backup.get_decryption_key()
|
||||
log.info(
|
||||
'Derived decryption key for backup at path %s is: "%s"',
|
||||
self.backup_path,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
"""Configuration shared by the iOS CLI and backup decryption implementation."""
|
||||
|
||||
DEFAULT_DECRYPT_WORKERS = 4
|
||||
MAX_DECRYPT_WORKERS = 32
|
||||
@@ -3,9 +3,16 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .backup_info import BackupInfo
|
||||
from .configuration_profiles import ConfigurationProfiles
|
||||
from .manifest import Manifest
|
||||
from .profile_events import ProfileEvents
|
||||
|
||||
BACKUP_MODULES = [BackupInfo, ConfigurationProfiles, Manifest, ProfileEvents]
|
||||
BACKUP_MODULES: list[type[MVTModule]] = [
|
||||
BackupInfo,
|
||||
ConfigurationProfiles,
|
||||
Manifest,
|
||||
ProfileEvents,
|
||||
]
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .analytics import Analytics
|
||||
from .analytics_ios_versions import AnalyticsIOSVersions
|
||||
from .cache_files import CacheFiles
|
||||
@@ -15,7 +17,7 @@ from .webkit_indexeddb import WebkitIndexedDB
|
||||
from .webkit_localstorage import WebkitLocalStorage
|
||||
from .webkit_safariviewservice import WebkitSafariViewService
|
||||
|
||||
FS_MODULES = [
|
||||
FS_MODULES: list[type[MVTModule]] = [
|
||||
CacheFiles,
|
||||
Filesystem,
|
||||
Netusage,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .applications import Applications
|
||||
from .calendar import Calendar
|
||||
from .calls import Calls
|
||||
@@ -26,8 +28,9 @@ 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 = [
|
||||
MIXED_MODULES: list[type[MVTModule]] = [
|
||||
Calls,
|
||||
ChromeFavicon,
|
||||
ChromeHistory,
|
||||
@@ -47,6 +50,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))
|
||||
@@ -101,6 +101,11 @@ class SMS(IOSExtraction):
|
||||
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)
|
||||
|
||||
@@ -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,6 +136,49 @@ 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
|
||||
@@ -70,6 +192,11 @@ class Whatsapp(IOSExtraction):
|
||||
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
|
||||
@@ -141,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,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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/
|
||||
|
||||
"""Stable functions and modules which plugins can import.
|
||||
|
||||
Anything else in mvt can be imported too, and may change
|
||||
between releases without notice.
|
||||
"""
|
||||
|
||||
from mvt.android.modules.androidqf.base import AndroidQFModule
|
||||
from mvt.android.modules.backup.base import BackupModule as AndroidBackupModule
|
||||
from mvt.android.modules.bugreport.base import BugReportModule
|
||||
from mvt.common.alerts import Alert, AlertLevel
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.config import settings
|
||||
from mvt.common.module import DatabaseCorruptedError, DatabaseNotFoundError, MVTModule
|
||||
from mvt.common.module_loader import get_plugin_logger
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.plugin_config import (
|
||||
MVTPluginSettings,
|
||||
PluginConfigLoadError,
|
||||
plugin_config_path,
|
||||
plugin_data_folder,
|
||||
plugin_env_prefix,
|
||||
)
|
||||
from mvt.common.utils import (
|
||||
convert_chrometime_to_datetime,
|
||||
convert_datetime_to_iso,
|
||||
convert_mactime_to_datetime,
|
||||
convert_mactime_to_iso,
|
||||
convert_unix_to_iso,
|
||||
convert_unix_to_utc_datetime,
|
||||
)
|
||||
from mvt.common.version import MVT_VERSION
|
||||
from mvt.ios.modules.base import IOSExtraction
|
||||
from mvt.ios.modules.sysdiagnose.base import SysdiagnoseExtraction
|
||||
|
||||
__all__ = [
|
||||
# Classes a plugin subclasses.
|
||||
"MVTModule",
|
||||
"IOSExtraction",
|
||||
"SysdiagnoseExtraction",
|
||||
"AndroidQFModule",
|
||||
"AndroidBackupModule",
|
||||
"BugReportModule",
|
||||
"Command",
|
||||
# Results and alerts.
|
||||
"ModuleAtomicResult",
|
||||
"ModuleResults",
|
||||
"ModuleSerializedResult",
|
||||
"Alert",
|
||||
"AlertLevel",
|
||||
# Errors a module raises.
|
||||
"DatabaseNotFoundError",
|
||||
"DatabaseCorruptedError",
|
||||
# Settings.
|
||||
"settings",
|
||||
"MVTPluginSettings",
|
||||
"PluginConfigLoadError",
|
||||
"plugin_config_path",
|
||||
"plugin_data_folder",
|
||||
"plugin_env_prefix",
|
||||
# Logging.
|
||||
"get_plugin_logger",
|
||||
# Timestamps.
|
||||
"convert_chrometime_to_datetime",
|
||||
"convert_datetime_to_iso",
|
||||
"convert_mactime_to_datetime",
|
||||
"convert_mactime_to_iso",
|
||||
"convert_unix_to_iso",
|
||||
"convert_unix_to_utc_datetime",
|
||||
# MVT's version.
|
||||
"MVT_VERSION",
|
||||
]
|
||||
@@ -23,7 +23,7 @@ class TestDumpsysAccessibilityArtifact:
|
||||
assert len(da.results) == 4
|
||||
assert da.results[0]["package_name"] == "com.android.settings"
|
||||
assert (
|
||||
da.results[0]["service"]
|
||||
da.results[0]["component"]
|
||||
== "com.android.settings/com.samsung.android.settings.development.gpuwatch.GPUWatchInterceptor"
|
||||
)
|
||||
|
||||
@@ -37,7 +37,9 @@ class TestDumpsysAccessibilityArtifact:
|
||||
da.parse(data)
|
||||
assert len(da.results) == 1
|
||||
assert da.results[0]["package_name"] == "com.malware.accessibility"
|
||||
assert da.results[0]["service"] == "com.malware.service.malwareservice"
|
||||
assert da.results[0]["service_name"] == "com.malware.service.malwareservice"
|
||||
assert da.results[0]["enabled"] is True
|
||||
assert da.results[0]["installed"] is False
|
||||
|
||||
def test_accessibility_service_alert(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
@@ -52,6 +54,22 @@ class TestDumpsysAccessibilityArtifact:
|
||||
assert da.alertstore.alerts[0].level == AlertLevel.MEDIUM
|
||||
assert da.alertstore.alerts[0].event == da.results[0]
|
||||
|
||||
def test_same_component_is_kept_for_each_user(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
da.parse(
|
||||
"""User state[attributes:{id=0
|
||||
installed services: {
|
||||
0 : com.example/.Service
|
||||
}
|
||||
User state[attributes:{id=10
|
||||
installed services: {
|
||||
0 : com.example/.Service
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
assert [result["user_id"] for result in da.results] == [0, 10]
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
file = get_artifact("android_data/dumpsys_accessibility.txt")
|
||||
|
||||
@@ -3,12 +3,27 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestDumpsysADBArtifact:
|
||||
def test_parsing_binary_xml_recovers_key(self):
|
||||
public_key = base64.b64encode(bytes(range(256)))
|
||||
keystore = DumpsysADBArtifact().parse_binary_xml(
|
||||
b"ABX\x00binary-key=" + public_key + b" user@host\x00lastConnection"
|
||||
)
|
||||
|
||||
assert len(keystore) == 1
|
||||
assert keystore[0]["key"] == public_key.decode()
|
||||
assert keystore[0]["user"] == "user@host"
|
||||
assert keystore[0]["last_connected"] is None
|
||||
|
||||
def test_parsing(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
file = get_artifact("android_data/dumpsys_adb.txt")
|
||||
@@ -114,3 +129,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 == []
|
||||
@@ -25,10 +25,37 @@ class TestDumpsysAppopsArtifact:
|
||||
assert da.results[0]["uid"] == "0"
|
||||
assert len(da.results[0]["permissions"]) == 1
|
||||
assert da.results[0]["permissions"][0]["name"] == "MANAGE_IPSEC_TUNNELS"
|
||||
assert da.results[0]["permissions"][0]["access"] == "allow"
|
||||
assert da.results[0]["permissions"][0]["mode"] == "allow"
|
||||
assert da.results[6]["package_name"] == "com.sec.factory.camera"
|
||||
assert len(da.results[6]["permissions"][1]["entries"]) == 1
|
||||
assert len(da.results[11]["permissions"]) == 4
|
||||
wake_lock = next(
|
||||
permission
|
||||
for permission in da.results[11]["permissions"]
|
||||
if permission["name"] == "WAKE_LOCK"
|
||||
)
|
||||
assert wake_lock["entries"][0]["duration"] == "+126ms"
|
||||
|
||||
def test_running_and_attribution_are_retained(self):
|
||||
da = DumpsysAppopsArtifact()
|
||||
da.parse(
|
||||
""" Uid 0:
|
||||
state=cch
|
||||
Package com.example:
|
||||
CAMERA (allow):
|
||||
camera=[
|
||||
Access: [fg-s] 2025-01-01 00:00:00.000 (-1s) duration=+2ms
|
||||
]
|
||||
RECORD_AUDIO (allow):
|
||||
Running start at: +3s
|
||||
"""
|
||||
)
|
||||
|
||||
camera = da.results[0]["permissions"][0]["entries"][0]
|
||||
running = da.results[0]["permissions"][1]["entries"][0]
|
||||
assert camera["attribution"] == "camera"
|
||||
assert running["event"] == "running"
|
||||
assert running["relative_time"] == "+3s"
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
da = DumpsysAppopsArtifact()
|
||||
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user