mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-31 02:06:34 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9190e05ef | ||
|
|
df540d7c41 | ||
|
|
706e81dff2 | ||
|
|
3ba15716a2 | ||
|
|
8068569dce | ||
|
|
6e9408e30c | ||
|
|
db809cbc46 | ||
|
|
f4bfc39c27 | ||
|
|
a645913376 | ||
|
|
4990b22259 |
No files matched your search
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v7
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.11"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v10.0.0
|
uses: astral-sh/setup-uv@v10.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v7
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.11"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v10.0.0
|
uses: astral-sh/setup-uv@v10.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
python-version: ['3.11', '3.12', '3.13', '3.14', '3.15']
|
||||||
env:
|
env:
|
||||||
# Takes precedence over .python-version, which otherwise makes `uv run`
|
# Takes precedence over .python-version, which otherwise makes `uv run`
|
||||||
# rebuild the venv with 3.10 and test every matrix entry on 3.10.
|
# rebuild the venv with 3.11 and test every matrix entry on 3.11.
|
||||||
UV_PYTHON: ${{ matrix.python-version }}
|
UV_PYTHON: ${{ matrix.python-version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.10
|
3.11
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Create main image
|
# Create main image
|
||||||
FROM ubuntu:24.04 AS main
|
FROM ubuntu:24.04 AS main
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||||
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
||||||
@@ -62,4 +62,4 @@ WORKDIR /home/cases
|
|||||||
RUN echo 'echo "Mobile Verification Toolkit @ Docker\n------------------------------------\n\nYou can find information about how to use this image for Android (https://github.com/mvt-project/mvt/tree/master/docs/android) and iOS (https://github.com/mvt-project/mvt/tree/master/docs/ios) in the official docs of the project.\n"' >> ~/.bashrc \
|
RUN echo 'echo "Mobile Verification Toolkit @ Docker\n------------------------------------\n\nYou can find information about how to use this image for Android (https://github.com/mvt-project/mvt/tree/master/docs/android) and iOS (https://github.com/mvt-project/mvt/tree/master/docs/ios) in the official docs of the project.\n"' >> ~/.bashrc \
|
||||||
&& echo 'echo "Note that to perform the debug via USB you might need to give the Docker image access to the USB using \"docker run -it --privileged -v /dev/bus/usb:/dev/bus/usb mvt\" or, preferably, the \"--device=\" parameter.\n"' >> ~/.bashrc
|
&& echo 'echo "Note that to perform the debug via USB you might need to give the Docker image access to the USB using \"docker run -it --privileged -v /dev/bus/usb:/dev/bus/usb mvt\" or, preferably, the \"--device=\" parameter.\n"' >> ~/.bashrc
|
||||||
|
|
||||||
CMD /bin/bash
|
CMD [ "/bin/bash" ]
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# Create main image
|
# Create main image
|
||||||
FROM python:3.10.14-alpine3.20 AS main
|
FROM python:3.11.17-alpine3.23 AS main
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||||
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
||||||
@@ -8,7 +8,7 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
|
|||||||
LABEL org.opencontainers.image.title="Mobile Verification Toolkit (Android)"
|
LABEL org.opencontainers.image.title="Mobile Verification Toolkit (Android)"
|
||||||
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
|
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
|
||||||
LABEL org.opencontainers.image.licenses="MVT License 1.1"
|
LABEL org.opencontainers.image.licenses="MVT License 1.1"
|
||||||
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.10.14-alpine3.20
|
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.11.17-alpine3.23
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Main image
|
# Main image
|
||||||
# ----------
|
# ----------
|
||||||
FROM ubuntu:24.04 AS main
|
FROM ubuntu:24.04 AS main
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||||
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
||||||
|
|||||||
@@ -86,11 +86,6 @@ writing and installing them, and the
|
|||||||
[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
|
[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
|
||||||
for the entry points a package registers commands in.
|
for the entry points a package registers commands in.
|
||||||
|
|
||||||
Extraction commands run independent modules concurrently with four workers by
|
|
||||||
default. Use `--jobs 1` for sequential execution or another positive value to
|
|
||||||
set the worker limit. Dependencies retain their declared ordering, and parallel
|
|
||||||
console logs are printed as grouped module blocks.
|
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -46,33 +46,6 @@ on it, and runs the rest of the analysis. Selecting such a module with
|
|||||||
A cycle in the dependency graph is a programming error rather than a
|
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.
|
configuration problem: the command logs a warning and runs no modules at all.
|
||||||
|
|
||||||
## Parallel module execution
|
|
||||||
|
|
||||||
Extraction modules run concurrently, using four worker threads by default. Use
|
|
||||||
`--jobs INTEGER` to change the worker limit, or `--jobs 1` for sequential
|
|
||||||
execution and live per-line logging:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvt-ios check-backup --jobs 8 --output ./out ./backup
|
|
||||||
```
|
|
||||||
|
|
||||||
The scheduler starts a module only after all of its declared dependencies have
|
|
||||||
completed. Results, alerts, and timeline entries are still aggregated in stable
|
|
||||||
topological order. During parallel execution, console records from each module
|
|
||||||
are buffered and printed together as a labeled block when that module finishes;
|
|
||||||
`command.log` is written immediately and retains the complete log stream.
|
|
||||||
|
|
||||||
Parallel-safe modules must log through `self.log`, must not write directly to
|
|
||||||
stdout or stderr, and must not mutate shared global state. A module that uses a
|
|
||||||
non-thread-safe resource or direct terminal output can opt out:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class TerminalModule(MVTModule):
|
|
||||||
parallel_safe = False
|
|
||||||
```
|
|
||||||
|
|
||||||
Such modules run synchronously and exclusively after active workers finish.
|
|
||||||
|
|
||||||
## Custom modules
|
## Custom modules
|
||||||
|
|
||||||
MVT's module-running `check-*` commands can run forensic modules which are not
|
MVT's module-running `check-*` commands can run forensic modules which are not
|
||||||
@@ -409,8 +382,7 @@ module came from:
|
|||||||
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
|
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
|
||||||
take to avoid inefficient code paths as we add new modules.
|
take to avoid inefficient code paths as we add new modules.
|
||||||
|
|
||||||
MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_PROFILE` environment variable. Profiling forces sequential execution even
|
MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_PROFILE` environment variable.
|
||||||
when `--jobs` is greater than one.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
||||||
|
|||||||
+1
-1
@@ -66,4 +66,4 @@ docker run --rm -it \
|
|||||||
ghcr.io/mvt-project/mvt:latest-ios lockdown info
|
ghcr.io/mvt-project/mvt:latest-ios lockdown info
|
||||||
```
|
```
|
||||||
|
|
||||||
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
|
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
|
Before proceeding, please note that MVT requires Python 3.11 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
|
||||||
|
|
||||||
## Dependencies on Linux
|
## Dependencies on Linux
|
||||||
|
|
||||||
|
|||||||
+20
@@ -18,8 +18,28 @@ markdown_extensions:
|
|||||||
use_pygments: true
|
use_pygments: true
|
||||||
theme:
|
theme:
|
||||||
name: material
|
name: material
|
||||||
|
logo: mvt.png
|
||||||
|
favicon: favicon.png
|
||||||
features:
|
features:
|
||||||
- tabs
|
- tabs
|
||||||
|
palette:
|
||||||
|
- media: "(prefers-color-scheme)"
|
||||||
|
primary: black
|
||||||
|
toggle:
|
||||||
|
icon: material/link
|
||||||
|
name: Switch to light mode
|
||||||
|
- media: "(prefers-color-scheme: light)"
|
||||||
|
scheme: default
|
||||||
|
primary: black
|
||||||
|
toggle:
|
||||||
|
icon: material/toggle-switch
|
||||||
|
name: Switch to dark mode
|
||||||
|
- media: "(prefers-color-scheme: dark)"
|
||||||
|
scheme: slate
|
||||||
|
primary: black
|
||||||
|
toggle:
|
||||||
|
icon: material/toggle-switch-off
|
||||||
|
name: Switch to system preference
|
||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
- mkdocstrings
|
- mkdocstrings
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ dependencies = [
|
|||||||
"python-dateutil==2.9.0.post0",
|
"python-dateutil==2.9.0.post0",
|
||||||
"tzdata==2026.3",
|
"tzdata==2026.3",
|
||||||
]
|
]
|
||||||
requires-python = ">= 3.10"
|
requires-python = ">= 3.11"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
homepage = "https://docs.mvt.re/en/latest/"
|
homepage = "https://docs.mvt.re/en/latest/"
|
||||||
|
|||||||
+4
-17
@@ -28,7 +28,6 @@ from mvt.common.help import (
|
|||||||
HELP_MSG_DISABLE_UPDATE_CHECK,
|
HELP_MSG_DISABLE_UPDATE_CHECK,
|
||||||
HELP_MSG_HASHES,
|
HELP_MSG_HASHES,
|
||||||
HELP_MSG_IOC,
|
HELP_MSG_IOC,
|
||||||
HELP_MSG_JOBS,
|
|
||||||
HELP_MSG_LIST_MODULES,
|
HELP_MSG_LIST_MODULES,
|
||||||
HELP_MSG_LOAD_MODULE,
|
HELP_MSG_LOAD_MODULE,
|
||||||
HELP_MSG_MODULE,
|
HELP_MSG_MODULE,
|
||||||
@@ -161,9 +160,8 @@ def check_adb(ctx):
|
|||||||
"zone is read from persist.sys.timezone in the bugreport itself."
|
"zone is read from persist.sys.timezone in the bugreport itself."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
|
||||||
)
|
)
|
||||||
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
|
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -176,7 +174,6 @@ def check_bugreport(
|
|||||||
load_module,
|
load_module,
|
||||||
timezone,
|
timezone,
|
||||||
verbose,
|
verbose,
|
||||||
jobs,
|
|
||||||
bugreport_path,
|
bugreport_path,
|
||||||
):
|
):
|
||||||
from .cmd_check_bugreport import CmdAndroidCheckBugreport
|
from .cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||||
@@ -199,7 +196,6 @@ def check_bugreport(
|
|||||||
disable_version_check=_get_disable_flags(ctx)[0],
|
disable_version_check=_get_disable_flags(ctx)[0],
|
||||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if list_modules:
|
if list_modules:
|
||||||
@@ -241,9 +237,8 @@ def check_bugreport(
|
|||||||
)
|
)
|
||||||
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
|
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
|
||||||
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
|
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
|
||||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
|
||||||
)
|
)
|
||||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -256,7 +251,6 @@ def check_backup(
|
|||||||
non_interactive,
|
non_interactive,
|
||||||
backup_password,
|
backup_password,
|
||||||
verbose,
|
verbose,
|
||||||
jobs,
|
|
||||||
backup_path,
|
backup_path,
|
||||||
):
|
):
|
||||||
from .cmd_check_backup import CmdAndroidCheckBackup
|
from .cmd_check_backup import CmdAndroidCheckBackup
|
||||||
@@ -278,7 +272,6 @@ def check_backup(
|
|||||||
disable_version_check=_get_disable_flags(ctx)[0],
|
disable_version_check=_get_disable_flags(ctx)[0],
|
||||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if list_modules:
|
if list_modules:
|
||||||
@@ -321,9 +314,8 @@ def check_backup(
|
|||||||
)
|
)
|
||||||
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
|
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
|
||||||
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
|
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
|
||||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
|
||||||
)
|
)
|
||||||
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
|
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -340,7 +332,6 @@ def check_androidqf(
|
|||||||
non_interactive,
|
non_interactive,
|
||||||
backup_password,
|
backup_password,
|
||||||
verbose,
|
verbose,
|
||||||
jobs,
|
|
||||||
androidqf_path,
|
androidqf_path,
|
||||||
):
|
):
|
||||||
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
||||||
@@ -364,7 +355,6 @@ def check_androidqf(
|
|||||||
disable_version_check=_get_disable_flags(ctx)[0],
|
disable_version_check=_get_disable_flags(ctx)[0],
|
||||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if list_modules:
|
if list_modules:
|
||||||
@@ -413,9 +403,8 @@ def check_androidqf(
|
|||||||
"time instead of UTC."
|
"time instead of UTC."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
|
||||||
)
|
)
|
||||||
@click.argument("LOGS_PATH", type=click.Path(exists=True))
|
@click.argument("LOGS_PATH", type=click.Path(exists=True))
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -428,7 +417,6 @@ def check_intrusion_logs(
|
|||||||
load_module,
|
load_module,
|
||||||
timezone,
|
timezone,
|
||||||
verbose,
|
verbose,
|
||||||
jobs,
|
|
||||||
logs_path,
|
logs_path,
|
||||||
):
|
):
|
||||||
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
||||||
@@ -449,7 +437,6 @@ def check_intrusion_logs(
|
|||||||
disable_version_check=_get_disable_flags(ctx)[0],
|
disable_version_check=_get_disable_flags(ctx)[0],
|
||||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if list_modules:
|
if list_modules:
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
|||||||
disable_version_check: bool = False,
|
disable_version_check: bool = False,
|
||||||
disable_indicator_check: bool = False,
|
disable_indicator_check: bool = False,
|
||||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||||
jobs: int = 4,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
target_path=target_path,
|
target_path=target_path,
|
||||||
@@ -69,7 +68,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
|||||||
disable_version_check=disable_version_check,
|
disable_version_check=disable_version_check,
|
||||||
disable_indicator_check=disable_indicator_check,
|
disable_indicator_check=disable_indicator_check,
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.platform = "android"
|
self.platform = "android"
|
||||||
@@ -261,7 +259,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
|||||||
hashes=self.hashes,
|
hashes=self.hashes,
|
||||||
sub_command=True,
|
sub_command=True,
|
||||||
custom_modules=self.custom_modules,
|
custom_modules=self.custom_modules,
|
||||||
jobs=self.jobs,
|
|
||||||
)
|
)
|
||||||
cmd.from_zip(bugreport)
|
cmd.from_zip(bugreport)
|
||||||
cmd.run()
|
cmd.run()
|
||||||
@@ -293,7 +290,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
|||||||
hashes=self.hashes,
|
hashes=self.hashes,
|
||||||
sub_command=True,
|
sub_command=True,
|
||||||
custom_modules=self.custom_modules,
|
custom_modules=self.custom_modules,
|
||||||
jobs=self.jobs,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
cmd.from_ab(backup)
|
cmd.from_ab(backup)
|
||||||
@@ -380,7 +376,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
|||||||
hashes=self.hashes,
|
hashes=self.hashes,
|
||||||
sub_command=True,
|
sub_command=True,
|
||||||
custom_modules=self.custom_modules,
|
custom_modules=self.custom_modules,
|
||||||
jobs=self.jobs,
|
|
||||||
)
|
)
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from .modules.backup import BACKUP_MODULES
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_BACKUP_PASSWORD_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
class InvalidAndroidBackup(Exception):
|
class InvalidAndroidBackup(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -47,7 +49,6 @@ class CmdAndroidCheckBackup(Command):
|
|||||||
disable_version_check: bool = False,
|
disable_version_check: bool = False,
|
||||||
disable_indicator_check: bool = False,
|
disable_indicator_check: bool = False,
|
||||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||||
jobs: int = 4,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
target_path=target_path,
|
target_path=target_path,
|
||||||
@@ -63,7 +64,6 @@ class CmdAndroidCheckBackup(Command):
|
|||||||
disable_version_check=disable_version_check,
|
disable_version_check=disable_version_check,
|
||||||
disable_indicator_check=disable_indicator_check,
|
disable_indicator_check=disable_indicator_check,
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.platform = "android"
|
self.platform = "android"
|
||||||
@@ -74,6 +74,15 @@ class CmdAndroidCheckBackup(Command):
|
|||||||
self.__tar: Optional[tarfile.TarFile] = None
|
self.__tar: Optional[tarfile.TarFile] = None
|
||||||
self.__files: List[str] = []
|
self.__files: List[str] = []
|
||||||
|
|
||||||
|
def _get_backup_password(self) -> str:
|
||||||
|
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)
|
||||||
|
return password
|
||||||
|
|
||||||
def from_ab(self, ab_file_bytes: bytes) -> None:
|
def from_ab(self, ab_file_bytes: bytes) -> None:
|
||||||
self.__type = "ab"
|
self.__type = "ab"
|
||||||
header = parse_ab_header(ab_file_bytes)
|
header = parse_ab_header(ab_file_bytes)
|
||||||
@@ -86,28 +95,40 @@ class CmdAndroidCheckBackup(Command):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
password = None
|
password = None
|
||||||
|
# A password typed at the prompt is asked for again if it is wrong.
|
||||||
|
# One given on the command line or in the environment is not.
|
||||||
|
max_attempts = 1
|
||||||
if header["encryption"] != "none":
|
if header["encryption"] != "none":
|
||||||
password = prompt_or_load_android_backup_password(log, self.module_options)
|
if not self.module_options.get("backup_password", None):
|
||||||
if not password:
|
max_attempts = MAX_BACKUP_PASSWORD_ATTEMPTS
|
||||||
|
password = self._get_backup_password()
|
||||||
|
attempt = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
tardata = parse_backup_file(ab_file_bytes, password=password)
|
||||||
|
break
|
||||||
|
except InvalidBackupPassword:
|
||||||
|
if attempt < max_attempts:
|
||||||
|
attempt += 1
|
||||||
|
log.error(
|
||||||
|
"Invalid backup password, try again (attempt %d of %d)",
|
||||||
|
attempt,
|
||||||
|
max_attempts,
|
||||||
|
)
|
||||||
|
password = self._get_backup_password()
|
||||||
|
continue
|
||||||
if self.sub_command:
|
if self.sub_command:
|
||||||
raise InvalidAndroidBackup("No backup password provided")
|
raise InvalidAndroidBackup("Invalid backup password")
|
||||||
log.critical("No backup password provided.")
|
log.critical("Invalid backup password")
|
||||||
|
sys.exit(1)
|
||||||
|
except AndroidBackupParsingError as exc:
|
||||||
|
if self.sub_command:
|
||||||
|
raise InvalidAndroidBackup(
|
||||||
|
f"Impossible to parse this backup file: {exc}"
|
||||||
|
) from exc
|
||||||
|
log.critical("Impossible to parse this backup file: %s", exc)
|
||||||
|
log.critical("Please use Android Backup Extractor (ABE) instead")
|
||||||
sys.exit(1)
|
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:
|
|
||||||
if self.sub_command:
|
|
||||||
raise InvalidAndroidBackup(
|
|
||||||
f"Impossible to parse this backup file: {exc}"
|
|
||||||
) from exc
|
|
||||||
log.critical("Impossible to parse this backup file: %s", exc)
|
|
||||||
log.critical("Please use Android Backup Extractor (ABE) instead")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
dbytes = io.BytesIO(tardata)
|
dbytes = io.BytesIO(tardata)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ class CmdAndroidCheckBugreport(Command):
|
|||||||
disable_version_check: bool = False,
|
disable_version_check: bool = False,
|
||||||
disable_indicator_check: bool = False,
|
disable_indicator_check: bool = False,
|
||||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||||
jobs: int = 4,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
target_path=target_path,
|
target_path=target_path,
|
||||||
@@ -56,7 +55,6 @@ class CmdAndroidCheckBugreport(Command):
|
|||||||
disable_version_check=disable_version_check,
|
disable_version_check=disable_version_check,
|
||||||
disable_indicator_check=disable_indicator_check,
|
disable_indicator_check=disable_indicator_check,
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.platform = "android"
|
self.platform = "android"
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
|||||||
disable_version_check: bool = False,
|
disable_version_check: bool = False,
|
||||||
disable_indicator_check: bool = False,
|
disable_indicator_check: bool = False,
|
||||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||||
jobs: int = 4,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
target_path=target_path,
|
target_path=target_path,
|
||||||
@@ -53,7 +52,6 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
|||||||
disable_version_check=disable_version_check,
|
disable_version_check=disable_version_check,
|
||||||
disable_indicator_check=disable_indicator_check,
|
disable_indicator_check=disable_indicator_check,
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.platform = "android"
|
self.platform = "android"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from rich.progress import track
|
||||||
|
|
||||||
from mvt.android.utils import (
|
from mvt.android.utils import (
|
||||||
BROWSER_INSTALLERS,
|
BROWSER_INSTALLERS,
|
||||||
PLAY_STORE_INSTALLERS,
|
PLAY_STORE_INSTALLERS,
|
||||||
@@ -151,9 +153,10 @@ class AQFPackages(AndroidQFModule):
|
|||||||
if total_hashes == 0:
|
if total_hashes == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log.info("Looking up %d package files on VirusTotal...", total_hashes)
|
progress_desc = f"Looking up {total_hashes} package files on VirusTotal..."
|
||||||
for index, file_hash in enumerate(files_by_hash):
|
for index, file_hash in enumerate(
|
||||||
self.log.debug("VirusTotal lookup %d/%d", index + 1, total_hashes)
|
track(files_by_hash, description=progress_desc)
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
results = virustotal_lookup(file_hash)
|
results = virustotal_lookup(file_hash)
|
||||||
except VTNoKey as exc:
|
except VTNoKey as exc:
|
||||||
|
|||||||
@@ -81,13 +81,12 @@ class AndroidQFModule(MVTModule):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_file_content(self, file_path):
|
def _get_file_content(self, file_path):
|
||||||
with self.resource_lock:
|
if self.archive:
|
||||||
if self.archive:
|
handle = self.archive.open(file_path)
|
||||||
handle = self.archive.open(file_path)
|
else:
|
||||||
else:
|
handle = open(os.path.join(self.parent_path, file_path), "rb")
|
||||||
handle = open(os.path.join(self.parent_path, file_path), "rb")
|
|
||||||
|
|
||||||
data = handle.read()
|
data = handle.read()
|
||||||
handle.close()
|
handle.close()
|
||||||
|
|
||||||
return data
|
return data
|
||||||
@@ -55,22 +55,21 @@ class BackupModule(MVTModule):
|
|||||||
return fnmatch.filter(self.files, pattern)
|
return fnmatch.filter(self.files, pattern)
|
||||||
|
|
||||||
def _get_file_content(self, file_path: str) -> bytes:
|
def _get_file_content(self, file_path: str) -> bytes:
|
||||||
with self.resource_lock:
|
handle = None
|
||||||
handle = None
|
if self.tar:
|
||||||
if self.tar:
|
try:
|
||||||
try:
|
member = self.tar.getmember(file_path)
|
||||||
member = self.tar.getmember(file_path)
|
handle = self.tar.extractfile(member)
|
||||||
handle = self.tar.extractfile(member)
|
if not handle:
|
||||||
if not handle:
|
raise ValueError(f"Could not extract file: {file_path}")
|
||||||
raise ValueError(f"Could not extract file: {file_path}")
|
except KeyError:
|
||||||
except KeyError:
|
raise FileNotFoundError(f"File not found in tar: {file_path}")
|
||||||
raise FileNotFoundError(f"File not found in tar: {file_path}")
|
elif self.backup_path:
|
||||||
elif self.backup_path:
|
handle = open(os.path.join(self.backup_path, file_path), "rb")
|
||||||
handle = open(os.path.join(self.backup_path, file_path), "rb")
|
else:
|
||||||
else:
|
raise ValueError("No backup path or tar file provided")
|
||||||
raise ValueError("No backup path or tar file provided")
|
|
||||||
|
|
||||||
data = handle.read()
|
data = handle.read()
|
||||||
handle.close()
|
handle.close()
|
||||||
|
|
||||||
return data
|
return data
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
# https://github.com/mvt-project/mvt/blob/main/LICENSE
|
# https://github.com/mvt-project/mvt/blob/main/LICENSE
|
||||||
import datetime
|
import datetime
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import io
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -13,7 +12,6 @@ from typing import List, Optional
|
|||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from mvt.android.artifacts.artifact import AndroidArtifact
|
|
||||||
from mvt.common.module import ModuleResults, MVTModule
|
from mvt.common.module import ModuleResults, MVTModule
|
||||||
|
|
||||||
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
|
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
|
||||||
@@ -26,24 +24,6 @@ SECTION_DURATION = re.compile(r"^-{3,}\s*[0-9.]+s was the duration of", re.IGNOR
|
|||||||
DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
|
DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
|
||||||
|
|
||||||
|
|
||||||
_DUMPSTATE_BYTES_CACHE_KEY = ("android_bugreport", "dumpstate", "bytes")
|
|
||||||
_DUMPSTATE_SECTIONS_CACHE_KEY = ("android_bugreport", "dumpstate", "sections")
|
|
||||||
_SYSTEM_PROPERTIES_SECTION = b"------ SYSTEM PROPERTIES"
|
|
||||||
_DUMPSYS_SEPARATORS = {
|
|
||||||
b"DUMP OF SERVICE accessibility:",
|
|
||||||
b"DUMP OF SERVICE adb:",
|
|
||||||
b"DUMP OF SERVICE appops:",
|
|
||||||
b"DUMP OF SERVICE batterystats:",
|
|
||||||
b"DUMP OF SERVICE dbinfo:",
|
|
||||||
b"DUMP OF SERVICE package:",
|
|
||||||
b"DUMP OF SERVICE platform_compat:",
|
|
||||||
}
|
|
||||||
_DUMPSTATE_DELIMITER = (
|
|
||||||
b"------------------------------------------------------------------------------"
|
|
||||||
)
|
|
||||||
_GETPROP_LINE_RE = re.compile(rb"\[(.+?)\]: \[(.+?)\]")
|
|
||||||
|
|
||||||
|
|
||||||
class BugReportModule(MVTModule):
|
class BugReportModule(MVTModule):
|
||||||
"""This class provides a base for all Android Bug Report modules."""
|
"""This class provides a base for all Android Bug Report modules."""
|
||||||
|
|
||||||
@@ -97,156 +77,45 @@ class BugReportModule(MVTModule):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _get_file_content(self, file_path: str) -> bytes:
|
def _get_file_content(self, file_path: str) -> bytes:
|
||||||
with self.resource_lock:
|
if self.zip_archive:
|
||||||
if self.zip_archive:
|
handle = self.zip_archive.open(file_path)
|
||||||
handle = self.zip_archive.open(file_path)
|
else:
|
||||||
else:
|
if not self.extract_path:
|
||||||
if not self.extract_path:
|
raise ValueError("extract_path is not set")
|
||||||
raise ValueError("extract_path is not set")
|
joined = os.path.join(self.extract_path, file_path)
|
||||||
joined = os.path.join(self.extract_path, file_path)
|
if (
|
||||||
if (
|
not Path(joined)
|
||||||
not Path(joined)
|
.resolve()
|
||||||
.resolve()
|
.is_relative_to(Path(self.extract_path).resolve())
|
||||||
.is_relative_to(Path(self.extract_path).resolve())
|
):
|
||||||
):
|
raise ValueError("unsafe file_path")
|
||||||
raise ValueError("unsafe file_path")
|
handle = open(joined, "rb")
|
||||||
handle = open(joined, "rb")
|
|
||||||
|
|
||||||
data = handle.read()
|
data = handle.read()
|
||||||
handle.close()
|
handle.close()
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _get_dumpstate_file(self) -> Optional[bytes]:
|
def _get_dumpstate_file(self) -> Optional[bytes]:
|
||||||
with self.resource_lock:
|
main_entry, dumpstate_log, dumpsys_txt = DUMPSTATE_ENTRY_POINTS
|
||||||
if _DUMPSTATE_BYTES_CACHE_KEY in self.resource_cache:
|
|
||||||
return self.resource_cache[_DUMPSTATE_BYTES_CACHE_KEY]
|
|
||||||
|
|
||||||
content = None
|
main = self._get_files_by_pattern(main_entry)
|
||||||
main_entry, dumpstate_log, dumpsys_txt = DUMPSTATE_ENTRY_POINTS
|
if main:
|
||||||
main = self._get_files_by_pattern(main_entry)
|
main_content = self._get_file_content(main[0])
|
||||||
if main:
|
try:
|
||||||
main_content = self._get_file_content(main[0])
|
return self._get_file_content(main_content.decode().strip())
|
||||||
try:
|
except KeyError:
|
||||||
content = self._get_file_content(main_content.decode().strip())
|
return None
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
dumpstate_logs = self._get_files_by_pattern(dumpstate_log)
|
|
||||||
if dumpstate_logs:
|
|
||||||
content = self._get_file_content(dumpstate_logs[0])
|
|
||||||
else:
|
|
||||||
dumpsys_files = self._get_files_by_pattern(dumpsys_txt)
|
|
||||||
if dumpsys_files:
|
|
||||||
content = self._get_file_content(dumpsys_files[0])
|
|
||||||
|
|
||||||
self.resource_cache[_DUMPSTATE_BYTES_CACHE_KEY] = content
|
dumpstate_logs = self._get_files_by_pattern(dumpstate_log)
|
||||||
return content
|
if dumpstate_logs:
|
||||||
|
return self._get_file_content(dumpstate_logs[0])
|
||||||
|
|
||||||
def _get_dumpstate_sections(self) -> dict[bytes, bytes]:
|
dumpsys_files = self._get_files_by_pattern(dumpsys_txt)
|
||||||
with self.resource_lock:
|
if dumpsys_files:
|
||||||
if _DUMPSTATE_SECTIONS_CACHE_KEY in self.resource_cache:
|
return self._get_file_content(dumpsys_files[0])
|
||||||
return self.resource_cache[_DUMPSTATE_SECTIONS_CACHE_KEY]
|
|
||||||
|
|
||||||
section_lines: dict[bytes, list[bytes]] = {
|
return None
|
||||||
separator: [] for separator in _DUMPSYS_SEPARATORS
|
|
||||||
}
|
|
||||||
section_lines[_SYSTEM_PROPERTIES_SECTION] = []
|
|
||||||
current_section = None
|
|
||||||
completed_sections = set()
|
|
||||||
in_system_properties = False
|
|
||||||
content = self._get_dumpstate_file()
|
|
||||||
|
|
||||||
if content:
|
|
||||||
for raw_line in io.BytesIO(content):
|
|
||||||
line = raw_line.rstrip(b"\r\n")
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
if stripped.startswith(_SYSTEM_PROPERTIES_SECTION):
|
|
||||||
in_system_properties = True
|
|
||||||
elif in_system_properties:
|
|
||||||
if stripped == b"------":
|
|
||||||
in_system_properties = False
|
|
||||||
elif _GETPROP_LINE_RE.search(line):
|
|
||||||
section_lines[_SYSTEM_PROPERTIES_SECTION].append(line)
|
|
||||||
|
|
||||||
if stripped in _DUMPSYS_SEPARATORS:
|
|
||||||
if current_section is not None:
|
|
||||||
completed_sections.add(current_section)
|
|
||||||
current_section = (
|
|
||||||
None if stripped in completed_sections else stripped
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if stripped.startswith(_DUMPSTATE_DELIMITER):
|
|
||||||
if current_section is not None:
|
|
||||||
completed_sections.add(current_section)
|
|
||||||
current_section = None
|
|
||||||
continue
|
|
||||||
|
|
||||||
if current_section is not None:
|
|
||||||
section_lines[current_section].append(line)
|
|
||||||
|
|
||||||
sections = {
|
|
||||||
separator: b"\n".join(lines)
|
|
||||||
for separator, lines in section_lines.items()
|
|
||||||
}
|
|
||||||
self.resource_cache[_DUMPSTATE_SECTIONS_CACHE_KEY] = sections
|
|
||||||
return sections
|
|
||||||
|
|
||||||
def _get_dumpsys_section_bytes(self, separator: bytes) -> Optional[bytes]:
|
|
||||||
sections = self._get_dumpstate_sections()
|
|
||||||
if separator in sections:
|
|
||||||
return sections[separator]
|
|
||||||
|
|
||||||
content = self._get_dumpstate_file()
|
|
||||||
if not content:
|
|
||||||
return None
|
|
||||||
return AndroidArtifact.extract_dumpsys_section(content, separator, binary=True)
|
|
||||||
|
|
||||||
def _get_dumpsys_section(
|
|
||||||
self, separator: str, errors: str = "replace"
|
|
||||||
) -> Optional[str]:
|
|
||||||
cache_key = (
|
|
||||||
"android_bugreport",
|
|
||||||
"dumpstate",
|
|
||||||
"section",
|
|
||||||
separator,
|
|
||||||
errors,
|
|
||||||
)
|
|
||||||
with self.resource_lock:
|
|
||||||
if cache_key in self.resource_cache:
|
|
||||||
return self.resource_cache[cache_key]
|
|
||||||
|
|
||||||
section_bytes = self._get_dumpsys_section_bytes(separator.encode("utf-8"))
|
|
||||||
section = (
|
|
||||||
section_bytes.decode("utf-8", errors=errors)
|
|
||||||
if section_bytes is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self.resource_cache[cache_key] = section
|
|
||||||
return section
|
|
||||||
|
|
||||||
def _get_system_properties_text(self, errors: str = "ignore") -> Optional[str]:
|
|
||||||
cache_key = (
|
|
||||||
"android_bugreport",
|
|
||||||
"dumpstate",
|
|
||||||
"system_properties",
|
|
||||||
errors,
|
|
||||||
)
|
|
||||||
with self.resource_lock:
|
|
||||||
if cache_key in self.resource_cache:
|
|
||||||
return self.resource_cache[cache_key]
|
|
||||||
|
|
||||||
sections = self._get_dumpstate_sections()
|
|
||||||
section_bytes = sections.get(_SYSTEM_PROPERTIES_SECTION)
|
|
||||||
section = (
|
|
||||||
section_bytes.decode("utf-8", errors=errors)
|
|
||||||
if section_bytes is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self.resource_cache[cache_key] = section
|
|
||||||
return section
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract_command_section(content: str, heading: str) -> str:
|
def extract_command_section(content: str, heading: str) -> str:
|
||||||
@@ -295,8 +164,7 @@ class BugReportModule(MVTModule):
|
|||||||
instant returned in UTC.
|
instant returned in UTC.
|
||||||
"""
|
"""
|
||||||
if self.zip_archive:
|
if self.zip_archive:
|
||||||
with self.resource_lock:
|
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
||||||
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
|
||||||
return datetime.datetime(*file_timetuple, tzinfo=self._device_timezone())
|
return datetime.datetime(*file_timetuple, tzinfo=self._device_timezone())
|
||||||
if not self.extract_path:
|
if not self.extract_path:
|
||||||
raise ValueError("extract_path is not set")
|
raise ValueError("extract_path is not set")
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
content = self._get_dumpsys_section(
|
full_dumpsys = self._get_dumpstate_file()
|
||||||
"DUMP OF SERVICE accessibility:", errors="ignore"
|
if not full_dumpsys:
|
||||||
)
|
|
||||||
if content is None:
|
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
content = self.extract_dumpsys_section(
|
||||||
|
full_dumpsys.decode("utf-8", errors="ignore"),
|
||||||
|
"DUMP OF SERVICE accessibility:",
|
||||||
|
)
|
||||||
self.parse(content)
|
self.parse(content)
|
||||||
|
|
||||||
listed = unnamed = 0
|
listed = unnamed = 0
|
||||||
|
|||||||
@@ -38,14 +38,19 @@ class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule):
|
|||||||
self.results = results if results else []
|
self.results = results if results else []
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
section = self._get_dumpsys_section("DUMP OF SERVICE package:", errors="ignore")
|
content = self._get_dumpstate_file()
|
||||||
if section is None:
|
if not content:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Extract package section
|
||||||
|
section = self.extract_dumpsys_section(
|
||||||
|
content.decode("utf-8", errors="ignore"), "DUMP OF SERVICE package:"
|
||||||
|
)
|
||||||
|
|
||||||
# Parse
|
# Parse
|
||||||
self.parse(section)
|
self.parse(section)
|
||||||
|
|
||||||
|
|||||||
@@ -38,14 +38,19 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
content = self._get_dumpsys_section_bytes(b"DUMP OF SERVICE adb:")
|
full_dumpsys = self._get_dumpstate_file()
|
||||||
if content is None:
|
if not full_dumpsys:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
content = self.extract_dumpsys_section(
|
||||||
|
full_dumpsys,
|
||||||
|
b"DUMP OF SERVICE adb:",
|
||||||
|
binary=True,
|
||||||
|
)
|
||||||
self.parse(content)
|
self.parse(content)
|
||||||
if self.results:
|
if self.results:
|
||||||
self.log.info(
|
self.log.info(
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
section = self._get_dumpsys_section("DUMP OF SERVICE appops:")
|
content = self._get_dumpstate_file()
|
||||||
if section is None:
|
if not content:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
section = self.extract_dumpsys_section(
|
||||||
|
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE appops:"
|
||||||
|
)
|
||||||
self.parse(section)
|
self.parse(section)
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE batterystats:")
|
content = self._get_dumpstate_file()
|
||||||
if dumpsys_section is None:
|
if not content:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
dumpsys_section = self.extract_dumpsys_section(
|
||||||
|
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
|
||||||
|
)
|
||||||
self.parse(dumpsys_section)
|
self.parse(dumpsys_section)
|
||||||
|
|
||||||
self.log.info("Extracted a total of %d battery daily stats", len(self.results))
|
self.log.info("Extracted a total of %d battery daily stats", len(self.results))
|
||||||
@@ -34,14 +34,17 @@ class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE batterystats:")
|
content = self._get_dumpstate_file()
|
||||||
if dumpsys_section is None:
|
if not content:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
dumpsys_section = self.extract_dumpsys_section(
|
||||||
|
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
|
||||||
|
)
|
||||||
self.parse(dumpsys_section)
|
self.parse(dumpsys_section)
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
|
|||||||
@@ -36,14 +36,18 @@ class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
section = self._get_dumpsys_section("DUMP OF SERVICE dbinfo:", errors="ignore")
|
data = self._get_dumpstate_file()
|
||||||
if section is None:
|
if not data:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
section = self.extract_dumpsys_section(
|
||||||
|
data.decode("utf-8", errors="ignore"), "DUMP OF SERVICE dbinfo:"
|
||||||
|
)
|
||||||
|
|
||||||
self.parse(section)
|
self.parse(section)
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Extracted a total of %d database connection pool records",
|
"Extracted a total of %d database connection pool records",
|
||||||
|
|||||||
@@ -36,13 +36,16 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
|
|||||||
self.results = [] if not results else results
|
self.results = [] if not results else results
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
content = self._get_system_properties_text()
|
content = self._get_dumpstate_file()
|
||||||
if content is None:
|
if not content:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.parse(content)
|
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))
|
self.log.info("Extracted %d Android system properties", len(self.results))
|
||||||
@@ -35,14 +35,17 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
content = self._get_dumpsys_section("DUMP OF SERVICE package:")
|
data = self._get_dumpstate_file()
|
||||||
if content is None:
|
if not data:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
content = self.extract_dumpsys_section(
|
||||||
|
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
|
||||||
|
)
|
||||||
self.parse(content)
|
self.parse(content)
|
||||||
|
|
||||||
for result in self.results:
|
for result in self.results:
|
||||||
|
|||||||
@@ -34,14 +34,18 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
content = self._get_dumpsys_section("DUMP OF SERVICE platform_compat:")
|
data = self._get_dumpstate_file()
|
||||||
if content is None:
|
if not data:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
decoded_data = data.decode("utf-8", errors="replace")
|
||||||
|
content = self.extract_dumpsys_section(
|
||||||
|
decoded_data, "DUMP OF SERVICE platform_compat:"
|
||||||
|
)
|
||||||
self.parse(content)
|
self.parse(content)
|
||||||
|
|
||||||
self.log.info("Found %d package compatibility overrides", len(self.results))
|
self.log.info("Found %d package compatibility overrides", len(self.results))
|
||||||
@@ -36,13 +36,17 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
|||||||
self.results = results if results else []
|
self.results = results if results else []
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE package:")
|
content = self._get_dumpstate_file()
|
||||||
if dumpsys_section is None:
|
if not content:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Unable to find dumpstate file. "
|
"Unable to find dumpstate file. "
|
||||||
"Did you provide a valid bug report archive?"
|
"Did you provide a valid bug report archive?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
dumpsys_section = self.extract_dumpsys_section(
|
||||||
|
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
|
||||||
|
)
|
||||||
|
|
||||||
self.parse(dumpsys_section)
|
self.parse(dumpsys_section)
|
||||||
self.log.info("Extracted %d package receivers", len(self.results))
|
self.log.info("Extracted %d package receivers", len(self.results))
|
||||||
+41
-226
@@ -6,10 +6,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from heapq import heappop, heappush
|
from heapq import heappop, heappush
|
||||||
@@ -22,7 +19,6 @@ from rich.text import Text
|
|||||||
from .alerts import AlertLevel, AlertStore
|
from .alerts import AlertLevel, AlertStore
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .indicators import Indicators
|
from .indicators import Indicators
|
||||||
from .log import MVTLogHandler, finish_module_log_buffer, start_module_log_buffer
|
|
||||||
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
|
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
|
||||||
from .module_loader import (
|
from .module_loader import (
|
||||||
ModuleOrigin,
|
ModuleOrigin,
|
||||||
@@ -56,7 +52,6 @@ class Command:
|
|||||||
disable_version_check: bool = False,
|
disable_version_check: bool = False,
|
||||||
disable_indicator_check: bool = False,
|
disable_indicator_check: bool = False,
|
||||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||||
jobs: int = 4,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = ""
|
self.name = ""
|
||||||
self.platform = ""
|
self.platform = ""
|
||||||
@@ -72,11 +67,6 @@ class Command:
|
|||||||
self.sub_command = sub_command
|
self.sub_command = sub_command
|
||||||
self.disable_version_check = disable_version_check
|
self.disable_version_check = disable_version_check
|
||||||
self.disable_indicator_check = disable_indicator_check
|
self.disable_indicator_check = disable_indicator_check
|
||||||
if jobs < 1:
|
|
||||||
raise ValueError("jobs must be at least 1")
|
|
||||||
self.jobs = jobs
|
|
||||||
self._resource_lock = threading.RLock()
|
|
||||||
self._resource_cache: dict[object, Any] = {}
|
|
||||||
|
|
||||||
# This dictionary can contain options that will be passed down from
|
# This dictionary can contain options that will be passed down from
|
||||||
# the Command to all modules. This can for example be used to pass
|
# the Command to all modules. This can for example be used to pass
|
||||||
@@ -720,22 +710,41 @@ class Command:
|
|||||||
|
|
||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
def _run_module(
|
def run(self) -> None:
|
||||||
self,
|
# The output folder and its command.log exist for a run, so that
|
||||||
module: type[MVTModule],
|
# listing modules or rejecting a target leaves nothing behind.
|
||||||
executed_by_type: dict[type[MVTModule], MVTModule],
|
# Resolving the module list can warn, so the log comes first.
|
||||||
capture_logs: bool,
|
self._create_storage()
|
||||||
) -> tuple[MVTModule, list[logging.LogRecord], bool]:
|
self._setup_logging()
|
||||||
"""Initialize and run one module, optionally buffering console logs."""
|
|
||||||
token = start_module_log_buffer() if capture_logs else None
|
ordered_modules = self._ordered_modules()
|
||||||
records: list[logging.LogRecord] = []
|
if ordered_modules is None:
|
||||||
encrypted = False
|
return
|
||||||
|
|
||||||
|
# Read the indicators once the run is certain to happen, before
|
||||||
|
# init() does any work on the target, so that a missing indicators
|
||||||
|
# file is reported first and every module gets the same object.
|
||||||
|
iocs = self.iocs
|
||||||
|
|
||||||
|
# Commands announce their target from init(), so it goes before the
|
||||||
|
# module list.
|
||||||
try:
|
try:
|
||||||
|
self.init()
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._log_loaded_modules(ordered_modules)
|
||||||
|
|
||||||
|
executed_by_type: dict[type[MVTModule], MVTModule] = {}
|
||||||
|
for module in ordered_modules:
|
||||||
|
|
||||||
|
module_logger = get_module_logger(module)
|
||||||
|
|
||||||
m = module(
|
m = module(
|
||||||
target_path=self.target_path,
|
target_path=self.target_path,
|
||||||
results_path=self.results_path,
|
results_path=self.results_path,
|
||||||
module_options=self.module_options,
|
module_options=self.module_options,
|
||||||
log=get_module_logger(module),
|
log=module_logger,
|
||||||
)
|
)
|
||||||
# Dependencies are keyed by the module class they declare, even
|
# Dependencies are keyed by the module class they declare, even
|
||||||
# when it was replaced, so that a module asking for the results
|
# when it was replaced, so that a module asking for the results
|
||||||
@@ -744,12 +753,10 @@ class Command:
|
|||||||
dependency: executed_by_type[resolved]
|
dependency: executed_by_type[resolved]
|
||||||
for dependency, resolved in self._module_dependencies(module)
|
for dependency, resolved in self._module_dependencies(module)
|
||||||
}
|
}
|
||||||
m.resource_lock = self._resource_lock
|
|
||||||
m.resource_cache = self._resource_cache
|
|
||||||
|
|
||||||
if self.iocs.total_ioc_count:
|
if iocs.total_ioc_count:
|
||||||
# IOC collections are shared read-only during module execution.
|
m.indicators = iocs
|
||||||
m.indicators = self.iocs
|
m.indicators.log = m.log
|
||||||
|
|
||||||
if self.serial:
|
if self.serial:
|
||||||
m.serial = self.serial
|
m.serial = self.serial
|
||||||
@@ -762,215 +769,23 @@ class Command:
|
|||||||
try:
|
try:
|
||||||
run_module(m)
|
run_module(m)
|
||||||
except EncryptedBackupError:
|
except EncryptedBackupError:
|
||||||
encrypted = True
|
self.log.critical(
|
||||||
finally:
|
"The backup appears to be encrypted. "
|
||||||
if token is not None:
|
"Please decrypt it first using `mvt-ios decrypt-backup`."
|
||||||
records = finish_module_log_buffer(token)
|
|
||||||
|
|
||||||
return m, records, encrypted
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _console_handler() -> Optional[MVTLogHandler]:
|
|
||||||
for handler in reversed(logging.getLogger("mvt").handlers):
|
|
||||||
if isinstance(handler, MVTLogHandler):
|
|
||||||
return handler
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _publish_completed_modules(
|
|
||||||
self,
|
|
||||||
ordered_modules: list[type[MVTModule]],
|
|
||||||
completed: dict[type[MVTModule], MVTModule],
|
|
||||||
) -> None:
|
|
||||||
"""Expose a stable snapshot of completed modules to initialization hooks."""
|
|
||||||
self.executed = [
|
|
||||||
completed[module] for module in ordered_modules if module in completed
|
|
||||||
]
|
|
||||||
|
|
||||||
def _aggregate_modules(
|
|
||||||
self,
|
|
||||||
ordered_modules: list[type[MVTModule]],
|
|
||||||
completed: dict[type[MVTModule], MVTModule],
|
|
||||||
) -> None:
|
|
||||||
"""Aggregate results in stable topological order."""
|
|
||||||
for module in ordered_modules:
|
|
||||||
if module not in completed:
|
|
||||||
continue
|
|
||||||
instance = completed[module]
|
|
||||||
self.timeline.extend(instance.timeline)
|
|
||||||
self.url_results.extend(instance.url_results)
|
|
||||||
self.alertstore.extend(instance.alertstore.alerts)
|
|
||||||
|
|
||||||
def _run_sequential(
|
|
||||||
self, ordered_modules: list[type[MVTModule]]
|
|
||||||
) -> tuple[dict[type[MVTModule], MVTModule], bool]:
|
|
||||||
completed: dict[type[MVTModule], MVTModule] = {}
|
|
||||||
for module in ordered_modules:
|
|
||||||
instance, _, encrypted = self._run_module(module, completed, False)
|
|
||||||
if encrypted:
|
|
||||||
return completed, True
|
|
||||||
completed[module] = instance
|
|
||||||
self._publish_completed_modules(ordered_modules, completed)
|
|
||||||
return completed, False
|
|
||||||
|
|
||||||
def _run_parallel(
|
|
||||||
self, ordered_modules: list[type[MVTModule]], jobs: int
|
|
||||||
) -> tuple[dict[type[MVTModule], MVTModule], bool]:
|
|
||||||
"""Run a stable module DAG with bounded worker threads."""
|
|
||||||
completed: dict[type[MVTModule], MVTModule] = {}
|
|
||||||
scheduled: set[type[MVTModule]] = set()
|
|
||||||
running: dict[Future, type[MVTModule]] = {}
|
|
||||||
completion_queue: queue.Queue[Future] = queue.Queue()
|
|
||||||
handler = self._console_handler()
|
|
||||||
status = None
|
|
||||||
fatal = False
|
|
||||||
|
|
||||||
def ready_modules() -> list[type[MVTModule]]:
|
|
||||||
return [
|
|
||||||
module
|
|
||||||
for module in ordered_modules
|
|
||||||
if module not in scheduled
|
|
||||||
and all(
|
|
||||||
dependency in completed
|
|
||||||
for _, dependency in self._module_dependencies(module)
|
|
||||||
)
|
)
|
||||||
]
|
|
||||||
|
|
||||||
def update_status() -> None:
|
|
||||||
if status is None:
|
|
||||||
return
|
return
|
||||||
names = ", ".join(module.__name__ for module in running.values())
|
|
||||||
status.update(
|
|
||||||
f"Modules: {len(completed)}/{len(ordered_modules)} complete"
|
|
||||||
+ (f"; running {names}" if names else "")
|
|
||||||
)
|
|
||||||
|
|
||||||
if handler and handler.console.is_terminal:
|
self.executed.append(m)
|
||||||
status = handler.console.status("")
|
executed_by_type[module] = m
|
||||||
status.start()
|
self.timeline.extend(m.timeline)
|
||||||
|
self.url_results.extend(m.url_results)
|
||||||
try:
|
self.alertstore.extend(m.alertstore.alerts)
|
||||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
|
||||||
while len(completed) < len(ordered_modules):
|
|
||||||
ready = ready_modules()
|
|
||||||
|
|
||||||
# An unsafe module is a scheduler barrier: it starts only
|
|
||||||
# after active workers drain and blocks later ready work.
|
|
||||||
if ready and not ready[0].parallel_safe:
|
|
||||||
if running:
|
|
||||||
future = completion_queue.get()
|
|
||||||
module = running.pop(future)
|
|
||||||
instance, records, encrypted = future.result()
|
|
||||||
if handler:
|
|
||||||
handler.emit_module_records(module.__name__, records)
|
|
||||||
if encrypted:
|
|
||||||
fatal = True
|
|
||||||
break
|
|
||||||
completed[module] = instance
|
|
||||||
self._publish_completed_modules(ordered_modules, completed)
|
|
||||||
update_status()
|
|
||||||
continue
|
|
||||||
|
|
||||||
module = ready[0]
|
|
||||||
scheduled.add(module)
|
|
||||||
if status is not None:
|
|
||||||
status.update(
|
|
||||||
f"Modules: {len(completed)}/{len(ordered_modules)} "
|
|
||||||
f"complete; running {module.__name__}"
|
|
||||||
)
|
|
||||||
instance, records, encrypted = self._run_module(
|
|
||||||
module, completed, True
|
|
||||||
)
|
|
||||||
if handler:
|
|
||||||
handler.emit_module_records(module.__name__, records)
|
|
||||||
if encrypted:
|
|
||||||
fatal = True
|
|
||||||
break
|
|
||||||
completed[module] = instance
|
|
||||||
self._publish_completed_modules(ordered_modules, completed)
|
|
||||||
update_status()
|
|
||||||
continue
|
|
||||||
|
|
||||||
for module in ready:
|
|
||||||
if len(running) >= jobs or not module.parallel_safe:
|
|
||||||
break
|
|
||||||
scheduled.add(module)
|
|
||||||
future = executor.submit(
|
|
||||||
self._run_module, module, dict(completed), True
|
|
||||||
)
|
|
||||||
running[future] = module
|
|
||||||
future.add_done_callback(completion_queue.put)
|
|
||||||
|
|
||||||
update_status()
|
|
||||||
if not running:
|
|
||||||
break
|
|
||||||
|
|
||||||
future = completion_queue.get()
|
|
||||||
module = running.pop(future)
|
|
||||||
instance, records, encrypted = future.result()
|
|
||||||
if handler:
|
|
||||||
handler.emit_module_records(module.__name__, records)
|
|
||||||
if encrypted:
|
|
||||||
fatal = True
|
|
||||||
break
|
|
||||||
completed[module] = instance
|
|
||||||
self._publish_completed_modules(ordered_modules, completed)
|
|
||||||
update_status()
|
|
||||||
|
|
||||||
if fatal:
|
|
||||||
for future in running:
|
|
||||||
future.cancel()
|
|
||||||
# The executor context waits for already-active work. Its
|
|
||||||
# results are intentionally discarded after the fatal error.
|
|
||||||
finally:
|
|
||||||
if status is not None:
|
|
||||||
status.stop()
|
|
||||||
|
|
||||||
return completed, fatal
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
self._create_storage()
|
|
||||||
self._setup_logging()
|
|
||||||
|
|
||||||
ordered_modules = self._ordered_modules()
|
|
||||||
if ordered_modules is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Load shared indicators before touching the acquisition.
|
|
||||||
self.iocs
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.init()
|
|
||||||
except NotImplementedError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self._log_loaded_modules(ordered_modules)
|
|
||||||
|
|
||||||
jobs = self.jobs
|
|
||||||
if settings.PROFILE and jobs > 1:
|
|
||||||
self.log.warning(
|
|
||||||
"MVT_PROFILE is enabled; forcing sequential module execution."
|
|
||||||
)
|
|
||||||
jobs = 1
|
|
||||||
|
|
||||||
if jobs == 1:
|
|
||||||
completed, fatal = self._run_sequential(ordered_modules)
|
|
||||||
else:
|
|
||||||
completed, fatal = self._run_parallel(ordered_modules, jobs)
|
|
||||||
|
|
||||||
self._aggregate_modules(ordered_modules, completed)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.finish()
|
self.finish()
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if fatal:
|
|
||||||
self.log.critical(
|
|
||||||
"The backup appears to be encrypted. "
|
|
||||||
"Please decrypt it first using `mvt-ios decrypt-backup`."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# We only store the timeline from the parent/main command
|
# We only store the timeline from the parent/main command
|
||||||
if self.sub_command:
|
if self.sub_command:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ HELP_MSG_LOAD_MODULE = (
|
|||||||
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
|
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
|
||||||
HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
|
HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
|
||||||
HELP_MSG_VERBOSE = "Verbose mode"
|
HELP_MSG_VERBOSE = "Verbose mode"
|
||||||
HELP_MSG_JOBS = "Number of extraction modules to run concurrently"
|
|
||||||
HELP_MSG_VERBOSE_COMMAND = (
|
HELP_MSG_VERBOSE_COMMAND = (
|
||||||
"Verbose mode (kept for compatibility, pass --verbose before the command "
|
"Verbose mode (kept for compatibility, pass --verbose before the command "
|
||||||
"name instead)"
|
"name instead)"
|
||||||
|
|||||||
+1
-41
@@ -4,8 +4,6 @@
|
|||||||
# https://license.mvt.re/1.1/
|
# https://license.mvt.re/1.1/
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
from contextvars import ContextVar, Token
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -29,33 +27,16 @@ logging.addLevelName(HIGH_ALERT, "HIGH")
|
|||||||
logging.addLevelName(CRITICAL_ALERT, "CRITICAL")
|
logging.addLevelName(CRITICAL_ALERT, "CRITICAL")
|
||||||
|
|
||||||
|
|
||||||
_module_log_buffer: ContextVar[Optional[list[logging.LogRecord]]] = ContextVar(
|
|
||||||
"mvt_module_log_buffer", default=None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def start_module_log_buffer() -> Token:
|
|
||||||
"""Capture console records in the current module execution context."""
|
|
||||||
return _module_log_buffer.set([])
|
|
||||||
|
|
||||||
|
|
||||||
def finish_module_log_buffer(token: Token) -> list[logging.LogRecord]:
|
|
||||||
records = _module_log_buffer.get() or []
|
|
||||||
_module_log_buffer.reset(token)
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
class MVTLogHandler(RichHandler):
|
class MVTLogHandler(RichHandler):
|
||||||
def __init__(self, console: Optional[Console] = None, level: int = logging.DEBUG):
|
def __init__(self, console: Optional[Console] = None, level: int = logging.DEBUG):
|
||||||
super().__init__(console=console, level=level)
|
super().__init__(console=console, level=level)
|
||||||
self._console_lock = threading.RLock()
|
|
||||||
|
|
||||||
def __add_prefix_space(self, level: str) -> str:
|
def __add_prefix_space(self, level: str) -> str:
|
||||||
max_length = len("CRITICAL ALERT")
|
max_length = len("CRITICAL ALERT")
|
||||||
space = max_length - len(level)
|
space = max_length - len(level)
|
||||||
return f"{level}{' ' * space}"
|
return f"{level}{' ' * space}"
|
||||||
|
|
||||||
def _emit_record(self, record: logging.LogRecord) -> None:
|
def emit(self, record: logging.LogRecord):
|
||||||
try:
|
try:
|
||||||
msg = rf"[grey50]\[{record.name}][/] {self.format(record)}"
|
msg = rf"[grey50]\[{record.name}][/] {self.format(record)}"
|
||||||
|
|
||||||
@@ -82,24 +63,3 @@ class MVTLogHandler(RichHandler):
|
|||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.handleError(record)
|
self.handleError(record)
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord):
|
|
||||||
buffer = _module_log_buffer.get()
|
|
||||||
if buffer is not None:
|
|
||||||
# More than one CLI can be imported in-process (notably in tests),
|
|
||||||
# which can install multiple console handlers for the same record.
|
|
||||||
if not buffer or buffer[-1] is not record:
|
|
||||||
buffer.append(record)
|
|
||||||
return
|
|
||||||
|
|
||||||
with self._console_lock:
|
|
||||||
self._emit_record(record)
|
|
||||||
|
|
||||||
def emit_module_records(
|
|
||||||
self, module_name: str, records: list[logging.LogRecord]
|
|
||||||
) -> None:
|
|
||||||
"""Print one module's captured records without interleaving."""
|
|
||||||
with self._console_lock:
|
|
||||||
self.console.print(f"[bold cyan]--- {module_name} ---[/bold cyan]")
|
|
||||||
for record in records:
|
|
||||||
self._emit_record(record)
|
|
||||||
@@ -8,7 +8,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import threading
|
|
||||||
from dataclasses import asdict, is_dataclass
|
from dataclasses import asdict, is_dataclass
|
||||||
from typing import Any, Dict, Optional, Sequence
|
from typing import Any, Dict, Optional, Sequence
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ class MVTModule:
|
|||||||
slug: Optional[str] = None
|
slug: Optional[str] = None
|
||||||
dependencies: Sequence[type["MVTModule"]] = ()
|
dependencies: Sequence[type["MVTModule"]] = ()
|
||||||
supported_commands: Sequence[tuple[str, str]] = ()
|
supported_commands: Sequence[tuple[str, str]] = ()
|
||||||
parallel_safe: bool = True
|
|
||||||
# A custom module can name a module class it supersedes, usually a
|
# 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
|
# 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
|
# dropped from the run and this module takes its place, including in the
|
||||||
@@ -92,12 +90,6 @@ class MVTModule:
|
|||||||
self.timeline: ModuleTimeline = []
|
self.timeline: ModuleTimeline = []
|
||||||
self.url_results: list[URLResult] = []
|
self.url_results: list[URLResult] = []
|
||||||
self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {}
|
self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {}
|
||||||
# Commands replace this with a command-scoped lock for modules sharing
|
|
||||||
# archive handles or other non-thread-safe read resources.
|
|
||||||
self.resource_lock = threading.RLock()
|
|
||||||
# Commands also replace this with a command-scoped cache. Cached values
|
|
||||||
# must be immutable, because parallel modules can read them concurrently.
|
|
||||||
self.resource_cache: Dict[object, Any] = {}
|
|
||||||
|
|
||||||
def get_dependency_results(
|
def get_dependency_results(
|
||||||
self, module_class: type["MVTModule"]
|
self, module_class: type["MVTModule"]
|
||||||
@@ -193,20 +185,17 @@ class MVTModule:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
timeline_set = set()
|
timeline_set = set()
|
||||||
deduplicated = []
|
|
||||||
for record in timeline:
|
for record in timeline:
|
||||||
serialized = json.dumps(
|
timeline_set.add(
|
||||||
asdict(record)
|
json.dumps(
|
||||||
if is_dataclass(record) and not isinstance(record, type)
|
asdict(record)
|
||||||
else record,
|
if is_dataclass(record) and not isinstance(record, type)
|
||||||
sort_keys=True,
|
else record,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if serialized in timeline_set:
|
|
||||||
continue
|
|
||||||
timeline_set.add(serialized)
|
|
||||||
deduplicated.append(json.loads(serialized))
|
|
||||||
|
|
||||||
return deduplicated
|
return [json.loads(record) for record in timeline_set]
|
||||||
|
|
||||||
def to_timeline(self) -> None:
|
def to_timeline(self) -> None:
|
||||||
"""Convert results into a timeline."""
|
"""Convert results into a timeline."""
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"""Password prompts with masked keyboard feedback.
|
"""Password prompts with masked keyboard feedback.
|
||||||
|
|
||||||
This is based on the ``echo_char`` support added to :mod:`getpass` in Python
|
This is based on the ``echo_char`` support added to :mod:`getpass` in Python
|
||||||
3.14. MVT supports Python 3.10 and later, so it cannot use that API directly.
|
3.14. MVT supports Python 3.11 and later, so it cannot use that API directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|||||||
+5
-10
@@ -36,7 +36,6 @@ from mvt.common.help import (
|
|||||||
HELP_MSG_LOAD_MODULE,
|
HELP_MSG_LOAD_MODULE,
|
||||||
HELP_MSG_MODULE,
|
HELP_MSG_MODULE,
|
||||||
HELP_MSG_VERBOSE,
|
HELP_MSG_VERBOSE,
|
||||||
HELP_MSG_JOBS,
|
|
||||||
HELP_MSG_VERBOSE_COMMAND,
|
HELP_MSG_VERBOSE_COMMAND,
|
||||||
HELP_MSG_CHECK_FS,
|
HELP_MSG_CHECK_FS,
|
||||||
HELP_MSG_CHECK_IOCS,
|
HELP_MSG_CHECK_IOCS,
|
||||||
@@ -279,9 +278,8 @@ def extract_key(password, key_file, backup_path):
|
|||||||
help=HELP_MSG_LOAD_MODULE,
|
help=HELP_MSG_LOAD_MODULE,
|
||||||
)
|
)
|
||||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
|
||||||
)
|
)
|
||||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -295,7 +293,6 @@ def check_backup(
|
|||||||
load_module,
|
load_module,
|
||||||
hashes,
|
hashes,
|
||||||
verbose,
|
verbose,
|
||||||
jobs,
|
|
||||||
backup_path,
|
backup_path,
|
||||||
):
|
):
|
||||||
from .cmd_check_backup import CmdIOSCheckBackup
|
from .cmd_check_backup import CmdIOSCheckBackup
|
||||||
@@ -314,7 +311,6 @@ def check_backup(
|
|||||||
disable_version_check=_get_disable_flags(ctx)[0],
|
disable_version_check=_get_disable_flags(ctx)[0],
|
||||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if list_modules:
|
if list_modules:
|
||||||
@@ -353,9 +349,8 @@ def check_backup(
|
|||||||
help=HELP_MSG_LOAD_MODULE,
|
help=HELP_MSG_LOAD_MODULE,
|
||||||
)
|
)
|
||||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
|
||||||
)
|
)
|
||||||
@click.argument("DUMP_PATH", type=click.Path(exists=True))
|
@click.argument("DUMP_PATH", type=click.Path(exists=True))
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -369,7 +364,6 @@ def check_fs(
|
|||||||
load_module,
|
load_module,
|
||||||
hashes,
|
hashes,
|
||||||
verbose,
|
verbose,
|
||||||
jobs,
|
|
||||||
dump_path,
|
dump_path,
|
||||||
):
|
):
|
||||||
from .cmd_check_fs import CmdIOSCheckFS
|
from .cmd_check_fs import CmdIOSCheckFS
|
||||||
@@ -388,7 +382,6 @@ def check_fs(
|
|||||||
disable_version_check=_get_disable_flags(ctx)[0],
|
disable_version_check=_get_disable_flags(ctx)[0],
|
||||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if list_modules:
|
if list_modules:
|
||||||
@@ -427,7 +420,9 @@ def check_fs(
|
|||||||
help=HELP_MSG_LOAD_MODULE,
|
help=HELP_MSG_LOAD_MODULE,
|
||||||
)
|
)
|
||||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
@click.option(
|
||||||
|
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
|
||||||
|
)
|
||||||
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
|
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def check_sysdiagnose(
|
def check_sysdiagnose(
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class CmdIOSCheckBackup(Command):
|
|||||||
disable_version_check: bool = False,
|
disable_version_check: bool = False,
|
||||||
disable_indicator_check: bool = False,
|
disable_indicator_check: bool = False,
|
||||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||||
jobs: int = 4,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
target_path=target_path,
|
target_path=target_path,
|
||||||
@@ -54,7 +53,6 @@ class CmdIOSCheckBackup(Command):
|
|||||||
disable_version_check=disable_version_check,
|
disable_version_check=disable_version_check,
|
||||||
disable_indicator_check=disable_indicator_check,
|
disable_indicator_check=disable_indicator_check,
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.platform = "ios"
|
self.platform = "ios"
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class CmdIOSCheckFS(Command):
|
|||||||
disable_version_check: bool = False,
|
disable_version_check: bool = False,
|
||||||
disable_indicator_check: bool = False,
|
disable_indicator_check: bool = False,
|
||||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||||
jobs: int = 4,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
target_path=target_path,
|
target_path=target_path,
|
||||||
@@ -46,7 +45,6 @@ class CmdIOSCheckFS(Command):
|
|||||||
disable_version_check=disable_version_check,
|
disable_version_check=disable_version_check,
|
||||||
disable_indicator_check=disable_indicator_check,
|
disable_indicator_check=disable_indicator_check,
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=jobs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.platform = "ios"
|
self.platform = "ios"
|
||||||
|
|||||||
@@ -1224,6 +1224,10 @@
|
|||||||
"version": "18.7.10",
|
"version": "18.7.10",
|
||||||
"build": "22H374"
|
"build": "22H374"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"version": "18.7.11",
|
||||||
|
"build": "22H384"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "26",
|
"version": "26",
|
||||||
"build": "23A341"
|
"build": "23A341"
|
||||||
@@ -1283,5 +1287,13 @@
|
|||||||
{
|
{
|
||||||
"version": "27.0.1",
|
"version": "27.0.1",
|
||||||
"build": "24A446"
|
"build": "24A446"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "27.2",
|
||||||
|
"build": "24B91020"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "27.2",
|
||||||
|
"build": "24B119"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -4,13 +4,8 @@
|
|||||||
# https://license.mvt.re/1.1/
|
# https://license.mvt.re/1.1/
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from mvt.android.modules.bugreport.base import BugReportModule
|
|
||||||
from mvt.android.modules.bugreport.dumpsys_appops import DumpsysAppops
|
from mvt.android.modules.bugreport.dumpsys_appops import DumpsysAppops
|
||||||
from mvt.android.modules.bugreport.dumpsys_getprop import DumpsysGetProp
|
from mvt.android.modules.bugreport.dumpsys_getprop import DumpsysGetProp
|
||||||
from mvt.android.modules.bugreport.dumpsys_packages import DumpsysPackages
|
from mvt.android.modules.bugreport.dumpsys_packages import DumpsysPackages
|
||||||
@@ -123,53 +118,3 @@ class TestBugreportAnalysis:
|
|||||||
assert len(m.results) == 2
|
assert len(m.results) == 2
|
||||||
assert m.results[1]["pid"] == 3559
|
assert m.results[1]["pid"] == 3559
|
||||||
assert m.results[0]["sources"]["text"]["parsed"] is True
|
assert m.results[0]["sources"]["text"]["parsed"] is True
|
||||||
|
|
||||||
def test_dumpstate_section_is_cached_across_parallel_modules(self):
|
|
||||||
dumpstate = (
|
|
||||||
b"header\n"
|
|
||||||
b"------ SYSTEM PROPERTIES (getprop) ------\n"
|
|
||||||
b"[persist.sys.timezone]: [Europe/Berlin]\n"
|
|
||||||
b"------------------------------------------------------------------------------\n"
|
|
||||||
b"------\n"
|
|
||||||
b"DUMP OF SERVICE package:\n"
|
|
||||||
b"package data\n"
|
|
||||||
b"------------------------------------------------------------------------------\n"
|
|
||||||
b"DUMP OF SERVICE package:\n"
|
|
||||||
b"duplicate package data\n"
|
|
||||||
b"------------------------------------------------------------------------------\n"
|
|
||||||
b"DUMP OF SERVICE adb:\n"
|
|
||||||
b"adb data\n"
|
|
||||||
b"------------------------------------------------------------------------------\n"
|
|
||||||
)
|
|
||||||
archive = MagicMock()
|
|
||||||
archive.open.side_effect = lambda _: BytesIO(dumpstate)
|
|
||||||
shared_lock = threading.RLock()
|
|
||||||
shared_cache = {}
|
|
||||||
modules = []
|
|
||||||
|
|
||||||
for _ in range(4):
|
|
||||||
module = BugReportModule()
|
|
||||||
module.from_zip(archive, ["dumpState_test.log"])
|
|
||||||
module.resource_lock = shared_lock
|
|
||||||
module.resource_cache = shared_cache
|
|
||||||
modules.append(module)
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
||||||
sections = list(
|
|
||||||
executor.map(
|
|
||||||
lambda module: module._get_dumpsys_section(
|
|
||||||
"DUMP OF SERVICE package:"
|
|
||||||
),
|
|
||||||
modules,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert sections == ["package data"] * 4
|
|
||||||
assert all(section is sections[0] for section in sections)
|
|
||||||
assert modules[0]._get_dumpsys_section_bytes(b"DUMP OF SERVICE adb:") == (
|
|
||||||
b"adb data"
|
|
||||||
)
|
|
||||||
assert modules[0]._get_system_properties_text() == (
|
|
||||||
"[persist.sys.timezone]: [Europe/Berlin]"
|
|
||||||
)
|
|
||||||
archive.open.assert_called_once_with("dumpState_test.log")
|
|
||||||
+19
-304
@@ -5,22 +5,14 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from mvt.common.command import Command
|
|
||||||
from mvt.common.config import settings
|
|
||||||
from mvt.common.log import MVTLogHandler
|
|
||||||
from mvt.common.module import EncryptedBackupError, MVTModule
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from mvt.common.command import Command
|
||||||
from mvt.common.indicators import Indicators
|
from mvt.common.indicators import Indicators
|
||||||
|
from mvt.common.module import MVTModule
|
||||||
|
|
||||||
|
|
||||||
class RecordingModule(MVTModule):
|
class RecordingModule(MVTModule):
|
||||||
@@ -306,15 +298,6 @@ class TestCommand:
|
|||||||
alerts = json.loads((tmp_path / "alerts.json").read_text())
|
alerts = json.loads((tmp_path / "alerts.json").read_text())
|
||||||
assert alerts[0]["event"]["payload"] == "\\xa8\\xa9"
|
assert alerts[0]["event"]["payload"] == "\\xa8\\xa9"
|
||||||
|
|
||||||
def test_timeline_deduplication_preserves_first_seen_order(self):
|
|
||||||
timeline = [
|
|
||||||
{"timestamp": "same", "event": "first"},
|
|
||||||
{"timestamp": "same", "event": "second"},
|
|
||||||
{"timestamp": "same", "event": "first"},
|
|
||||||
]
|
|
||||||
|
|
||||||
assert MVTModule._deduplicate_timeline(timeline) == timeline[:2]
|
|
||||||
|
|
||||||
def test_stores_collected_urls(self, tmp_path):
|
def test_stores_collected_urls(self, tmp_path):
|
||||||
cmd = RecordingCommand(results_path=str(tmp_path))
|
cmd = RecordingCommand(results_path=str(tmp_path))
|
||||||
cmd.modules = [URLRecordingModule]
|
cmd.modules = [URLRecordingModule]
|
||||||
@@ -331,7 +314,7 @@ class TestCommand:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_modules_run_in_stable_topological_order(self):
|
def test_modules_run_in_stable_topological_order(self):
|
||||||
cmd = RecordingCommand(jobs=1)
|
cmd = RecordingCommand()
|
||||||
cmd.modules = [ThirdModule, IndependentModule, SecondModule, FirstModule]
|
cmd.modules = [ThirdModule, IndependentModule, SecondModule, FirstModule]
|
||||||
|
|
||||||
cmd.run()
|
cmd.run()
|
||||||
@@ -346,7 +329,7 @@ class TestCommand:
|
|||||||
assert second.results == ["first", "second"]
|
assert second.results == ["first", "second"]
|
||||||
|
|
||||||
def test_selected_module_runs_transitive_dependencies(self):
|
def test_selected_module_runs_transitive_dependencies(self):
|
||||||
cmd = RecordingCommand(module_name="ThirdModule", jobs=1)
|
cmd = RecordingCommand(module_name="ThirdModule")
|
||||||
cmd.modules = [ThirdModule, SecondModule, FirstModule, IndependentModule]
|
cmd.modules = [ThirdModule, SecondModule, FirstModule, IndependentModule]
|
||||||
|
|
||||||
cmd.run()
|
cmd.run()
|
||||||
@@ -357,38 +340,6 @@ class TestCommand:
|
|||||||
"ThirdModule",
|
"ThirdModule",
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.parametrize("jobs", [1, 2])
|
|
||||||
@pytest.mark.parametrize("parallel_safe", [False, True])
|
|
||||||
def test_initialization_hook_sees_completed_dependencies(self, jobs, parallel_safe):
|
|
||||||
class Prerequisite(FirstModule):
|
|
||||||
pass
|
|
||||||
|
|
||||||
Prerequisite.parallel_safe = parallel_safe
|
|
||||||
|
|
||||||
class Dependent(SecondModule):
|
|
||||||
dependencies = (Prerequisite,)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
self.results = self.get_dependency_results(Prerequisite) + ["second"]
|
|
||||||
|
|
||||||
class HookCommand(RecordingCommand):
|
|
||||||
def module_init(self, module):
|
|
||||||
if isinstance(module, Dependent):
|
|
||||||
self.seen = [
|
|
||||||
executed.results
|
|
||||||
for executed in self.executed
|
|
||||||
if isinstance(executed, Prerequisite)
|
|
||||||
]
|
|
||||||
|
|
||||||
cmd = HookCommand(jobs=jobs)
|
|
||||||
cmd.modules = [Dependent, Prerequisite]
|
|
||||||
|
|
||||||
cmd.run()
|
|
||||||
|
|
||||||
assert cmd.seen == [["first"]]
|
|
||||||
assert [type(module) for module in cmd.executed] == [Prerequisite, Dependent]
|
|
||||||
assert cmd.executed[1].results == ["first", "second"]
|
|
||||||
|
|
||||||
def test_circular_dependency_warns_and_stops(self, caplog):
|
def test_circular_dependency_warns_and_stops(self, caplog):
|
||||||
class CircularOne(RecordingModule):
|
class CircularOne(RecordingModule):
|
||||||
pass
|
pass
|
||||||
@@ -421,10 +372,7 @@ class TestCommand:
|
|||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert [type(module).__name__ for module in cmd.executed] == [
|
assert RecordingModule.run_order == ["IndependentModule", "FirstModule"]
|
||||||
"IndependentModule",
|
|
||||||
"FirstModule",
|
|
||||||
]
|
|
||||||
assert cmd.initialized
|
assert cmd.initialized
|
||||||
assert "Module DependentModule will be SKIPPED" in caplog.text
|
assert "Module DependentModule will be SKIPPED" in caplog.text
|
||||||
assert "depends on module UnavailableModule" in caplog.text
|
assert "depends on module UnavailableModule" in caplog.text
|
||||||
@@ -528,7 +476,7 @@ class TestCommand:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_selected_custom_module_runs(self):
|
def test_selected_custom_module_runs(self):
|
||||||
cmd = RecordingCommand(module_name="CustomIOSBackupModule", jobs=1)
|
cmd = RecordingCommand(module_name="CustomIOSBackupModule")
|
||||||
cmd.platform = "ios"
|
cmd.platform = "ios"
|
||||||
cmd.name = "check-backup"
|
cmd.name = "check-backup"
|
||||||
cmd.custom_modules = [CustomIOSBackupModule]
|
cmd.custom_modules = [CustomIOSBackupModule]
|
||||||
@@ -548,7 +496,7 @@ class TestCommand:
|
|||||||
assert RecordingModule.run_order == []
|
assert RecordingModule.run_order == []
|
||||||
|
|
||||||
def test_custom_module_dependencies_use_topological_order(self):
|
def test_custom_module_dependencies_use_topological_order(self):
|
||||||
cmd = RecordingCommand(module_name="CustomDependsOnBuiltin", jobs=1)
|
cmd = RecordingCommand(module_name="CustomDependsOnBuiltin")
|
||||||
cmd.platform = "ios"
|
cmd.platform = "ios"
|
||||||
cmd.name = "check-backup"
|
cmd.name = "check-backup"
|
||||||
cmd.modules = [SecondModule, FirstModule]
|
cmd.modules = [SecondModule, FirstModule]
|
||||||
@@ -558,228 +506,6 @@ class TestCommand:
|
|||||||
|
|
||||||
assert RecordingModule.run_order == ["FirstModule", "CustomDependsOnBuiltin"]
|
assert RecordingModule.run_order == ["FirstModule", "CustomDependsOnBuiltin"]
|
||||||
|
|
||||||
def test_parallel_modules_overlap_and_respect_worker_limit(self):
|
|
||||||
lock = threading.Lock()
|
|
||||||
barrier = threading.Barrier(2)
|
|
||||||
active = 0
|
|
||||||
maximum = 0
|
|
||||||
overlapped = []
|
|
||||||
|
|
||||||
class ParallelModule(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
nonlocal active, maximum
|
|
||||||
with lock:
|
|
||||||
active += 1
|
|
||||||
maximum = max(maximum, active)
|
|
||||||
try:
|
|
||||||
barrier.wait(timeout=2)
|
|
||||||
overlapped.append(self.__class__.__name__)
|
|
||||||
time.sleep(0.02)
|
|
||||||
finally:
|
|
||||||
with lock:
|
|
||||||
active -= 1
|
|
||||||
|
|
||||||
class ParallelOne(ParallelModule):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class ParallelTwo(ParallelModule):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class ParallelThree(ParallelModule):
|
|
||||||
pass
|
|
||||||
|
|
||||||
cmd = RecordingCommand(jobs=2)
|
|
||||||
cmd.modules = [ParallelOne, ParallelTwo, ParallelThree]
|
|
||||||
cmd.run()
|
|
||||||
|
|
||||||
assert maximum == 2
|
|
||||||
assert {"ParallelOne", "ParallelTwo"}.issubset(overlapped)
|
|
||||||
|
|
||||||
def test_dependencies_wait_and_transitive_dependencies_run_once(self):
|
|
||||||
prerequisite_finished = threading.Event()
|
|
||||||
runs = []
|
|
||||||
|
|
||||||
class Prerequisite(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
runs.append("prerequisite")
|
|
||||||
self.results = ["ready"]
|
|
||||||
prerequisite_finished.set()
|
|
||||||
|
|
||||||
class Dependent(RecordingModule):
|
|
||||||
dependencies = (Prerequisite,)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
assert prerequisite_finished.is_set()
|
|
||||||
assert self.get_dependency_results(Prerequisite) == ["ready"]
|
|
||||||
runs.append(self.__class__.__name__)
|
|
||||||
|
|
||||||
class DependentOne(Dependent):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class DependentTwo(Dependent):
|
|
||||||
pass
|
|
||||||
|
|
||||||
cmd = RecordingCommand(jobs=3)
|
|
||||||
cmd.modules = [DependentOne, DependentTwo, Prerequisite]
|
|
||||||
cmd.run()
|
|
||||||
|
|
||||||
assert runs.count("prerequisite") == 1
|
|
||||||
assert set(runs[1:]) == {"DependentOne", "DependentTwo"}
|
|
||||||
|
|
||||||
def test_parallel_unsafe_module_runs_exclusively(self):
|
|
||||||
lock = threading.Lock()
|
|
||||||
active = 0
|
|
||||||
unsafe_ran_exclusively = []
|
|
||||||
|
|
||||||
class SafeOne(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
nonlocal active
|
|
||||||
with lock:
|
|
||||||
active += 1
|
|
||||||
time.sleep(0.03)
|
|
||||||
with lock:
|
|
||||||
active -= 1
|
|
||||||
|
|
||||||
class Unsafe(RecordingModule):
|
|
||||||
parallel_safe = False
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
nonlocal active
|
|
||||||
with lock:
|
|
||||||
unsafe_ran_exclusively.append(active == 0)
|
|
||||||
active += 1
|
|
||||||
time.sleep(0.01)
|
|
||||||
with lock:
|
|
||||||
active -= 1
|
|
||||||
|
|
||||||
class SafeTwo(SafeOne):
|
|
||||||
pass
|
|
||||||
|
|
||||||
cmd = RecordingCommand(jobs=3)
|
|
||||||
cmd.modules = [SafeOne, Unsafe, SafeTwo]
|
|
||||||
cmd.run()
|
|
||||||
|
|
||||||
assert unsafe_ran_exclusively == [True]
|
|
||||||
|
|
||||||
def test_results_are_aggregated_in_topological_order(self):
|
|
||||||
release_first = threading.Event()
|
|
||||||
|
|
||||||
class SlowFirst(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
release_first.wait(timeout=2)
|
|
||||||
self.results = ["first"]
|
|
||||||
self.timeline = [{"module": "first"}]
|
|
||||||
self.alertstore.info("first", "", {"order": 1})
|
|
||||||
|
|
||||||
class FastSecond(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
self.results = ["second"]
|
|
||||||
self.timeline = [{"module": "second"}]
|
|
||||||
self.alertstore.info("second", "", {"order": 2})
|
|
||||||
release_first.set()
|
|
||||||
|
|
||||||
cmd = RecordingCommand(jobs=2)
|
|
||||||
cmd.modules = [SlowFirst, FastSecond]
|
|
||||||
cmd.run()
|
|
||||||
|
|
||||||
assert [type(module) for module in cmd.executed] == [SlowFirst, FastSecond]
|
|
||||||
assert [entry["module"] for entry in cmd.timeline] == ["first", "second"]
|
|
||||||
assert [alert.message for alert in cmd.alertstore.alerts] == ["first", "second"]
|
|
||||||
|
|
||||||
def test_parallel_console_logs_are_grouped_and_file_log_is_complete(
|
|
||||||
self, tmp_path
|
|
||||||
):
|
|
||||||
output = StringIO()
|
|
||||||
handler = MVTLogHandler(
|
|
||||||
console=Console(file=output, force_terminal=False, color_system=None)
|
|
||||||
)
|
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
||||||
logger = logging.getLogger("mvt")
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
release_slow = threading.Event()
|
|
||||||
|
|
||||||
class SlowLog(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
self.log.info("slow-a")
|
|
||||||
release_slow.wait(timeout=2)
|
|
||||||
self.log.info("slow-b")
|
|
||||||
|
|
||||||
class FastLog(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
self.log.info("fast-a")
|
|
||||||
self.log.info("fast-b")
|
|
||||||
release_slow.set()
|
|
||||||
|
|
||||||
SlowLog.__module__ = "mvt.tests.parallel"
|
|
||||||
FastLog.__module__ = "mvt.tests.parallel"
|
|
||||||
try:
|
|
||||||
cmd = RecordingCommand(results_path=str(tmp_path), jobs=2)
|
|
||||||
cmd.modules = [SlowLog, FastLog]
|
|
||||||
cmd.run()
|
|
||||||
finally:
|
|
||||||
logger.removeHandler(handler)
|
|
||||||
|
|
||||||
console_output = output.getvalue()
|
|
||||||
fast_block = (
|
|
||||||
console_output.index("--- FastLog ---"),
|
|
||||||
console_output.index("fast-a"),
|
|
||||||
console_output.index("fast-b"),
|
|
||||||
)
|
|
||||||
slow_block = (
|
|
||||||
console_output.index("--- SlowLog ---"),
|
|
||||||
console_output.index("slow-a"),
|
|
||||||
console_output.index("slow-b"),
|
|
||||||
)
|
|
||||||
assert fast_block == tuple(sorted(fast_block))
|
|
||||||
assert slow_block == tuple(sorted(slow_block))
|
|
||||||
assert fast_block[-1] < slow_block[0] or slow_block[-1] < fast_block[0]
|
|
||||||
file_output = (tmp_path / "command.log").read_text()
|
|
||||||
for message in ("slow-a", "slow-b", "fast-a", "fast-b"):
|
|
||||||
assert message in file_output
|
|
||||||
|
|
||||||
def test_encrypted_backup_stops_scheduling_and_cleans_up(self, caplog):
|
|
||||||
finish_called = []
|
|
||||||
|
|
||||||
class Encrypted(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
raise EncryptedBackupError
|
|
||||||
|
|
||||||
class NeverScheduled(RecordingModule):
|
|
||||||
dependencies = (Encrypted,)
|
|
||||||
|
|
||||||
class CleanupCommand(RecordingCommand):
|
|
||||||
def finish(self):
|
|
||||||
finish_called.append(True)
|
|
||||||
|
|
||||||
cmd = CleanupCommand(jobs=2)
|
|
||||||
cmd.modules = [Encrypted, NeverScheduled]
|
|
||||||
with caplog.at_level(logging.CRITICAL):
|
|
||||||
cmd.run()
|
|
||||||
|
|
||||||
assert finish_called == [True]
|
|
||||||
assert not any(isinstance(module, NeverScheduled) for module in cmd.executed)
|
|
||||||
assert "backup appears to be encrypted" in caplog.text
|
|
||||||
|
|
||||||
def test_profiling_forces_sequential_execution(self, monkeypatch, caplog):
|
|
||||||
run_order = []
|
|
||||||
|
|
||||||
class ProfileOne(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
run_order.append("one")
|
|
||||||
|
|
||||||
class ProfileTwo(RecordingModule):
|
|
||||||
def run(self):
|
|
||||||
run_order.append("two")
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "PROFILE", True)
|
|
||||||
cmd = RecordingCommand(jobs=4)
|
|
||||||
cmd.modules = [ProfileOne, ProfileTwo]
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
cmd.run()
|
|
||||||
|
|
||||||
assert run_order == ["one", "two"]
|
|
||||||
assert "forcing sequential module execution" in caplog.text
|
|
||||||
def test_custom_module_replaces_builtin(self, caplog):
|
def test_custom_module_replaces_builtin(self, caplog):
|
||||||
cmd = RecordingCommand()
|
cmd = RecordingCommand()
|
||||||
cmd.platform = "ios"
|
cmd.platform = "ios"
|
||||||
@@ -790,10 +516,7 @@ class TestCommand:
|
|||||||
with caplog.at_level(logging.INFO):
|
with caplog.at_level(logging.INFO):
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert [type(module).__name__ for module in cmd.executed] == [
|
assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"]
|
||||||
"IndependentModule",
|
|
||||||
"ReplacementModule",
|
|
||||||
]
|
|
||||||
assert cmd.module_replacements == {FirstModule: ReplacementModule}
|
assert cmd.module_replacements == {FirstModule: ReplacementModule}
|
||||||
assert (
|
assert (
|
||||||
"Module ReplacementModule from" in caplog.text
|
"Module ReplacementModule from" in caplog.text
|
||||||
@@ -809,10 +532,7 @@ class TestCommand:
|
|||||||
|
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert [type(module).__name__ for module in cmd.executed] == [
|
assert RecordingModule.run_order == ["FirstModule", "CustomIOSBackupModule"]
|
||||||
"FirstModule",
|
|
||||||
"CustomIOSBackupModule",
|
|
||||||
]
|
|
||||||
assert cmd.module_replacements == {}
|
assert cmd.module_replacements == {}
|
||||||
|
|
||||||
def test_unavailable_replaced_module_is_ignored(self, caplog):
|
def test_unavailable_replaced_module_is_ignored(self, caplog):
|
||||||
@@ -825,10 +545,7 @@ class TestCommand:
|
|||||||
with caplog.at_level(logging.INFO):
|
with caplog.at_level(logging.INFO):
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert [type(module).__name__ for module in cmd.executed] == [
|
assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"]
|
||||||
"IndependentModule",
|
|
||||||
"ReplacementModule",
|
|
||||||
]
|
|
||||||
assert cmd.module_replacements == {}
|
assert cmd.module_replacements == {}
|
||||||
assert "replaces module FirstModule" not in caplog.text
|
assert "replaces module FirstModule" not in caplog.text
|
||||||
|
|
||||||
@@ -946,7 +663,7 @@ class TestCommand:
|
|||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert [type(module).__name__ for module in cmd.executed] == [
|
assert RecordingModule.run_order == [
|
||||||
"ReplacementModule",
|
"ReplacementModule",
|
||||||
"OtherReplacementModule",
|
"OtherReplacementModule",
|
||||||
]
|
]
|
||||||
@@ -1005,9 +722,8 @@ class TestCommand:
|
|||||||
assert cmd.module_replacements == {}
|
assert cmd.module_replacements == {}
|
||||||
assert logged_substitutions(caplog) == []
|
assert logged_substitutions(caplog) == []
|
||||||
|
|
||||||
@pytest.mark.parametrize("jobs", [1, 4])
|
def test_modules_replacing_each_other_all_run(self, caplog):
|
||||||
def test_modules_replacing_each_other_all_run(self, caplog, jobs):
|
cmd = RecordingCommand()
|
||||||
cmd = RecordingCommand(jobs=jobs)
|
|
||||||
cmd.platform = "ios"
|
cmd.platform = "ios"
|
||||||
cmd.name = "check-backup"
|
cmd.name = "check-backup"
|
||||||
cmd.custom_modules = [MutualReplacementOne, MutualReplacementTwo]
|
cmd.custom_modules = [MutualReplacementOne, MutualReplacementTwo]
|
||||||
@@ -1015,7 +731,7 @@ class TestCommand:
|
|||||||
with caplog.at_level(logging.INFO):
|
with caplog.at_level(logging.INFO):
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert [type(module).__name__ for module in cmd.executed] == [
|
assert RecordingModule.run_order == [
|
||||||
"MutualReplacementOne",
|
"MutualReplacementOne",
|
||||||
"MutualReplacementTwo",
|
"MutualReplacementTwo",
|
||||||
]
|
]
|
||||||
@@ -1026,9 +742,8 @@ class TestCommand:
|
|||||||
)
|
)
|
||||||
assert logged_substitutions(caplog) == []
|
assert logged_substitutions(caplog) == []
|
||||||
|
|
||||||
@pytest.mark.parametrize("jobs", [1, 4])
|
def test_replacement_cycle_of_three_modules_all_run(self, caplog):
|
||||||
def test_replacement_cycle_of_three_modules_all_run(self, caplog, jobs):
|
cmd = RecordingCommand()
|
||||||
cmd = RecordingCommand(jobs=jobs)
|
|
||||||
cmd.platform = "ios"
|
cmd.platform = "ios"
|
||||||
cmd.name = "check-backup"
|
cmd.name = "check-backup"
|
||||||
cmd.custom_modules = [CycleOneModule, CycleTwoModule, CycleThreeModule]
|
cmd.custom_modules = [CycleOneModule, CycleTwoModule, CycleThreeModule]
|
||||||
@@ -1036,7 +751,7 @@ class TestCommand:
|
|||||||
with caplog.at_level(logging.INFO):
|
with caplog.at_level(logging.INFO):
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert [type(module).__name__ for module in cmd.executed] == [
|
assert RecordingModule.run_order == [
|
||||||
"CycleOneModule",
|
"CycleOneModule",
|
||||||
"CycleTwoModule",
|
"CycleTwoModule",
|
||||||
"CycleThreeModule",
|
"CycleThreeModule",
|
||||||
@@ -1119,7 +834,7 @@ class TestCommand:
|
|||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert [type(module).__name__ for module in cmd.executed] == [
|
assert RecordingModule.run_order == [
|
||||||
"SharedSlugModule",
|
"SharedSlugModule",
|
||||||
"OtherSharedSlugModule",
|
"OtherSharedSlugModule",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -59,6 +59,66 @@ class TestCheckAndroidBackupCommand:
|
|||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Invalid backup password" in caplog.text
|
assert "Invalid backup password" in caplog.text
|
||||||
|
|
||||||
|
def test_check_encrypted_backup_prompt_retry(self, mocker):
|
||||||
|
"""A wrong password at the prompt is asked for again"""
|
||||||
|
prompt_mock = mocker.patch(
|
||||||
|
"mvt.android.modules.backup.helpers.prompt_password",
|
||||||
|
side_effect=["wrong_password", TEST_BACKUP_PASSWORD],
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
|
||||||
|
result = runner.invoke(check_backup, [path])
|
||||||
|
|
||||||
|
assert prompt_mock.call_count == 2
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_check_encrypted_backup_prompt_gives_up(self, mocker, caplog):
|
||||||
|
"""The prompt gives up after three wrong passwords"""
|
||||||
|
prompt_mock = mocker.patch(
|
||||||
|
"mvt.android.modules.backup.helpers.prompt_password",
|
||||||
|
return_value="wrong_password",
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.CRITICAL):
|
||||||
|
result = runner.invoke(check_backup, [path])
|
||||||
|
|
||||||
|
assert prompt_mock.call_count == 3
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Invalid backup password" in caplog.text
|
||||||
|
|
||||||
|
def test_check_encrypted_backup_prompt_retry_empty(self, mocker, caplog):
|
||||||
|
"""An empty answer on a retry ends the check"""
|
||||||
|
prompt_mock = mocker.patch(
|
||||||
|
"mvt.android.modules.backup.helpers.prompt_password",
|
||||||
|
side_effect=["wrong_password", ""],
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.CRITICAL):
|
||||||
|
result = runner.invoke(check_backup, [path])
|
||||||
|
|
||||||
|
assert prompt_mock.call_count == 2
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "No backup password provided" in caplog.text
|
||||||
|
|
||||||
|
def test_check_encrypted_backup_cli_invalid_no_prompt(self, mocker):
|
||||||
|
"""A password given on the command line is not asked for again"""
|
||||||
|
prompt_mock = mocker.patch(
|
||||||
|
"mvt.android.modules.backup.helpers.prompt_password",
|
||||||
|
return_value=TEST_BACKUP_PASSWORD,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
|
||||||
|
result = runner.invoke(
|
||||||
|
check_backup, ["--backup-password", "invalid_password", path]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert prompt_mock.call_count == 0
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
def test_check_encrypted_backup_env(self, mocker):
|
def test_check_encrypted_backup_env(self, mocker):
|
||||||
"""Provide password as environment variable"""
|
"""Provide password as environment variable"""
|
||||||
prompt_mock = mocker.patch(
|
prompt_mock = mocker.patch(
|
||||||
|
|||||||
@@ -41,11 +41,3 @@ class TestCheckBackupCommand:
|
|||||||
result = runner.invoke(check_backup, [str(tmp_path)])
|
result = runner.invoke(check_backup, [str(tmp_path)])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "does not appear to be an iTunes backup folder" in caplog.text
|
assert "does not appear to be an iTunes backup folder" in caplog.text
|
||||||
|
|
||||||
def test_check_rejects_non_positive_jobs(self):
|
|
||||||
result = CliRunner().invoke(
|
|
||||||
check_backup, ["--jobs", "0", get_ios_backup_folder()]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.exit_code == 2
|
|
||||||
assert "Invalid value for '--jobs'" in result.output
|
|
||||||
@@ -100,8 +100,8 @@ class TestVerboseOnTheCheckCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert _console_level() == logging.DEBUG
|
assert _console_level() == logging.DEBUG
|
||||||
|
|
||||||
def test_the_command_option_says_it_is_kept_for_compatibility(self):
|
def test_the_command_option_is_hidden_from_the_help(self):
|
||||||
result = CliRunner().invoke(ios_cli, [*OFFLINE, "check-backup", "--help"])
|
result = CliRunner().invoke(ios_cli, [*OFFLINE, "check-backup", "--help"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "kept for compatibility" in result.output
|
assert "--verbose" not in result.output
|
||||||
@@ -288,19 +288,15 @@ def test_androidqf_propagates_custom_modules_to_nested_commands(tmp_path, monkey
|
|||||||
cmd = CmdAndroidCheckAndroidQF(
|
cmd = CmdAndroidCheckAndroidQF(
|
||||||
target_path=str(tmp_path),
|
target_path=str(tmp_path),
|
||||||
custom_modules=custom_modules,
|
custom_modules=custom_modules,
|
||||||
jobs=7,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def record_available(name):
|
def record_available(name):
|
||||||
def _record(command):
|
def _record(command):
|
||||||
records[name] = {
|
records[name] = [
|
||||||
"jobs": command.jobs,
|
module.__name__
|
||||||
"modules": [
|
for module in command._available_modules()
|
||||||
module.__name__
|
if module.__name__.startswith("Nested")
|
||||||
for module in command._available_modules()
|
]
|
||||||
if module.__name__.startswith("Nested")
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
return _record
|
return _record
|
||||||
|
|
||||||
@@ -343,7 +339,7 @@ def test_androidqf_propagates_custom_modules_to_nested_commands(tmp_path, monkey
|
|||||||
assert cmd.run_backup_cmd()
|
assert cmd.run_backup_cmd()
|
||||||
assert cmd.run_intrusion_logs_cmd()
|
assert cmd.run_intrusion_logs_cmd()
|
||||||
assert records == {
|
assert records == {
|
||||||
"bugreport": {"jobs": 7, "modules": ["NestedBugreportModule"]},
|
"bugreport": ["NestedBugreportModule"],
|
||||||
"backup": {"jobs": 7, "modules": ["NestedBackupModule"]},
|
"backup": ["NestedBackupModule"],
|
||||||
"intrusion_logs": {"jobs": 7, "modules": ["NestedIntrusionLogsModule"]},
|
"intrusion_logs": ["NestedIntrusionLogsModule"],
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15'",
|
"python_full_version >= '3.15'",
|
||||||
"python_full_version < '3.15'",
|
"python_full_version < '3.15'",
|
||||||
|
|||||||
Reference in new issue
Block a user