Compare commits

..
Author SHA1 Message Date
Donncha Ó Cearbhaill 1cf82f44e9 Document how to write MVT plugins
The custom modules page now leads with plugin packages. Loading module
files with --load-module and MVT_CUSTOM_MODULES moves to a section on
developing a module locally.

A new "Writing a module" section shows a module which subclasses
IOSExtraction. It lists each base class, the command pair it serves and the
helpers it provides. "Depending on a built-in module" says to import a
built-in class from its family package.

"Importing from MVT" says what mvt.plugin exports and what importing from
it means. The custom commands page shows a Command subclass which lists its
own modules. The sysdiagnose and plugin configuration pages import from
mvt.plugin.
2026-08-26 22:14:37 +02:00
Donncha Ó Cearbhaill da33c2fdb3 Add the mvt.plugin import surface
mvt.plugin re-exports the names a plugin needs from MVT under one import
path. It holds the module base classes and Command, the alert and result
types, the database errors a module raises, the timestamp converters, the
plugin settings API, MVT's settings, get_plugin_logger() and MVT_VERSION.

The names it exports are kept working on a best-effort basis. Changes to
them are announced in the release notes. Anything else in mvt can still be
imported, and may change between releases without notice.

get_plugin_logger(__name__) returns a logger under mvt.ext for plugin code
outside a module class. Its records then reach the console and the
command.log file of a run. A file loaded with --load-module or
--load-command is named after the file.
2026-08-26 21:41:49 +02:00
Donncha Ó Cearbhaill 1b163749b2 Pin that check-iocs re-checks the results of custom modules
check-iocs takes its custom modules from load_custom_modules() like every
check-* command and matches result files to modules by slug, so a plugin
module's stored results are re-checked whenever it declares the
check-iocs pair of its platform; nothing asserted it.
2026-08-26 13:52:10 +02:00
Donncha Ó Cearbhaill 70efefde22 Share the check-iocs module lists between the CLI and the code
check-iocs re-checks the results a previous run stored, so its module
list is every module of the platform that could have written one. Each
platform's CLI composed that list inline, concatenating the families by
hand, so the list existed only inside the click callback: anything else
needing to know what check-iocs runs had to build its own copy, and the
two could drift apart without a test noticing.

Give each platform a command_modules.py holding the one list, and have
its CLI assign it. The modules check-iocs runs are unchanged, and a
test pins each list to the families it is composed of.
2026-08-26 13:52:10 +02:00
Donncha Ó Cearbhaill b3585e76ad Add a --verbose option to the mvt, mvt-ios and mvt-android commands
Verbosity was an option of each module-running command, so
"mvt-ios --verbose check-backup" was a usage error, a plugin command had to
define a flag of its own, and there was no way to get debug output from mvt at
all.

The option now sits on the three commands themselves and sets the level of
MVT's console handler for the run, through set_verbose_logging(), before any
command runs. Plugin commands registered on any of the three CLIs get the
option for free and need none of their own.

The per-command --verbose of the check-* commands is kept for backward
compatibility. It only ever raises the level, so the CLI's choice is never
undone by a command's default, and its help text says it is kept for
compatibility. It is to be removed in a later release.
2026-08-26 13:31:26 +02:00
Donncha Ó Cearbhaill 1e45ead1a5 Find the console log handler by type when changing verbosity
set_verbose_logging() adjusted the first handler on the "mvt" logger,
whichever handler that happened to be. Anything else attaching a
handler to that logger - an embedding application, a plugin, a test
harness - had it mistaken for the console and raised or lowered behind
its back, and the same slot can hold the file handler a command
attaches to its output folder, whose level a --verbose flag should
never decide: command.log records the whole run either way.

Walk the handlers instead and adjust only MVT's own console handler,
found by its MVTLogHandler type. Every other handler on the logger is
left alone. Behaviour is otherwise unchanged.
2026-08-26 13:19:52 +02:00
Donncha Ó Cearbhaill 348b47db35 Add a plugins command to list installed plugins and check updates
Add a "plugins" command to the platform-neutral mvt command. "plugins
list" shows every installed plugin package with its version, where it was
installed from, how many forensic modules it contributes and which
commands it adds. "plugins check-updates" checks for updates immediately,
without waiting for the automatic check, and prints the command which
upgrades a plugin instead of installing anything.

It lives on mvt only. The packages it lists extend mvt-ios and mvt-android
too, but auditing them is not the job of a command which analyses one
platform, and the two platform CLIs should not carry commands which are
not about an acquisition.

The command is registered as a built-in, before any external command, so
that an installed package cannot replace this audit surface.
2026-08-26 13:14:56 +02:00
Donncha Ó Cearbhaill b768d5b86c Add plugin update checking
Report available updates to the installed MVT plugin packages in the
startup banner, for plugins installed from a package index and for
plugins installed directly from a repository. Repository installs pinned
to a commit or a tag are never reported as outdated.

MVT only prints the command which upgrades a plugin. Installing the
update stays a deliberate choice of the analyst. The check runs at most
once every twelve hours, and in between prints the findings of the
latest check which still apply to what is installed.

Nothing about the check can interrupt a running command: the parts of
the suggested command come from package metadata and are quoted for the
shell, the repository query refuses to prompt for credentials and never
passes metadata as a git option, and a corrupt or stale cache is
discarded rather than trusted.
2026-08-26 12:57:13 +02:00
Donncha Ó Cearbhaill c4215ec163 Point the README and the command docs at plugin packages
Loading a command by path is for local development; a package is how a
command is distributed. The README's summary of what extends MVT now
names plugin packages and nothing else, and the section on loading a
command file says what it is for: keeping a command being written
loadable without reinstalling its package after every change.
2026-08-26 12:43:24 +02:00
Donncha Ó Cearbhaill 36d1b27c4c Move shell completion to the mvt command and generate one script for every MVT command
Setting up shell completion had nothing to do with the acquisition of one
platform, yet it was a command of mvt-ios and mvt-android, each
generating the script of the program it ran under only. Completion now
leaves the platform CLIs for mvt, which emits one script covering mvt,
mvt-ios and mvt-android, installed as a single file loaded when the shell
starts. Click names the completion function of each program after the
program, so the three scripts concatenate without colliding, and fish
takes the file in conf.d rather than one named after a single command.

With one script there is nothing left for the platform commands to
generate, so their completion command goes, and with it the banner
suppression which was keyed on the command name: mvt-ios and mvt-android
now print the banner for every command they have.

The long help line says which commands the completion covers, so a
short_help keeps "mvt --help" from truncating it.
2026-08-26 12:43:12 +02:00
Donncha Ó Cearbhaill 6070fc06fb Reduce indent for MVT CLI header 2026-08-26 12:43:12 +02:00
Donncha Ó Cearbhaill c95e6659c6 Add a platform-neutral mvt command
Several MVT commands have nothing to do with the acquisition of one
platform, yet they were reachable only through mvt-ios and mvt-android.
Asking which version is installed or downloading the public indicators
meant picking one of the two platform commands arbitrarily, and each of
those tasks had to be written, documented and maintained twice.

Add a third console script, mvt, hosting the commands which belong to no
platform: version and download-iocs for now, with completion following
in a later commit.

Commands installed in the new mvt.cli_plugins entry-point group are
registered on mvt, and on mvt only, so that a command package chooses
the CLI each of its commands is added to: mvt.ios.cli_plugins for
mvt-ios, mvt.android.cli_plugins for mvt-android and mvt.cli_plugins for
mvt. A command wanted on both platform CLIs is registered in both
platform groups; no group adds a command to every CLI. The
MVT_CUSTOM_COMMANDS variable loads command files and folders into mvt
the way the platform variables already do for mvt-ios and mvt-android.

Run on its own, mvt prints the banner and its help instead of a usage
error. The help text reminds that the forensic analysis of an
acquisition runs through mvt-ios and mvt-android, so that the command
which knows nothing about acquisitions says where they are analysed.

version and download-iocs stay on mvt-ios and mvt-android for now, so
that no documented invocation stops working. They are to be dropped from
the platform CLIs in a later release, once mvt has been available long
enough for the change to be announced.

Unlike mvt-ios and mvt-android, which point at their subpackages, the
console script points at mvt.cli:main and the mvt package re-exports
nothing of it. Importing mvt has to stay cheap and free of side effects:
it is the package plugins import from, and pulling in Click, the CLI and
everything the commands import merely because something imported mvt
would work against that.

While here, give the version command of both platform CLIs the context
settings every other command already has, so that "mvt-ios version -h"
prints its help instead of failing on an unknown option.
2026-08-26 12:43:12 +02:00
Donncha Ó Cearbhaill f8456059db Register installed CLI plugins at program start
register_cli_plugins() ran while mvt.ios and mvt.android were being
imported, so importing any part of MVT executed the entry points of every
installed command package. That made plugin loading depend on import
order: a plugin importing from MVT while MVT was still initializing got an
ImportError and was quietly demoted to a broken command, and the same
plugin worked when MVT happened to be imported first.

Move the call into a main() function in each CLI module and point the
console scripts at it, so registration happens once when the program
starts and importing MVT no longer runs third-party code. For packagers:
mvt.ios:cli and mvt.android:cli stay importable, but a wrapper invoking
cli() directly no longer registers the installed plugin commands and
should call main() instead.
2026-08-26 12:18:45 +02:00
Donncha Ó Cearbhaill efbab29f94 Skip modules with unavailable dependencies instead of aborting the run
A module declaring a dependency its command does not provide made
_ordered_modules() give up on the whole run, so a single wrong declaration
in a module scoped to several commands turned a forensic analysis into
zero executed modules with one warning to explain it.

Drop only the modules that cannot run: the one with the unavailable
dependency, and anything depending on it. Each gets its own warning naming
the module missing a dependency and the dependency it is missing, and the
remaining modules run in the same stable topological order as before. A
cycle in the dependency graph is still a programming error and still stops
the run.
2026-08-26 12:09:49 +02:00
Donncha Ó Cearbhaill ecc22f54b7 Derive the data folder of a plugin from its settings class
A plugin with a settings class already names itself in `plugin_name`;
passing the name again to plugin_data_folder() repeats it and can drift.
Add a `data_folder()` class method on MVTPluginSettings which returns
plugin_data_folder() for the class's validated plugin name (works on the
class and on an instance); plugin_data_folder(name) stays as the function
underneath for plugins without a settings class.
2026-08-26 12:05:35 +02:00
Donncha Ó Cearbhaill 90fc0ae351 Add per-plugin data folders
Plugins had no sanctioned place to keep the data they persist, so the
plugin configuration documentation suggested a CACHE_FOLDER setting
defaulting to ~/.cache/example-plugin. That is a Linux convention which
is wrong on macOS, nothing expands or creates it, and it turns a path
into a setting a user can be asked to configure.

Add plugin_data_folder(), which returns the folder a plugin should use
for caches, downloaded artifacts, synchronization state or anything
else it writes to disk, and creates it if it is missing. The plugin
name is validated before anything is created, so a name holding a path
separator raises an error and leaves no folder behind, and calling the
function again returns the same folder with its contents untouched.

The folder sits under plugin-data rather than under the plugins folder
which holds the settings files. On macOS the configuration folder and
the data folder are the same directory, so reusing the plugins name
would leave each plugin's data folder in among the settings files.

Both the plugin-data folder and the folder of each plugin are created
with 0700 permissions. MVT is a forensic tool, and what a plugin keeps
there, such as API responses or sample metadata, is private by default.
The path is resolved on every call, as the configuration folder already
is, so it follows the current environment rather than whatever it was
when MVT was imported.

The documentation now points plugins at the helper, and the example
settings class carries a plain integer setting in place of its cache
folder.
2026-08-26 11:53:47 +02:00
Donncha Ó Cearbhaill dab82e38d0 Add namespaced plugin configuration support
Plugin packages need somewhere to keep their own settings, but MVT
rewrites its config.yaml with only the fields it knows about, so any
foreign section is dropped. Add MVTPluginSettings, a pydantic-settings
base class that gives each plugin its own file under the MVT config
folder and its own MVT_PLUGIN_<NAME>_ environment variable namespace.

Settings resolve from constructor arguments, then the environment, then
the plugin file, then the field defaults. Saving skips the values the
environment currently supplies, so credentials passed as environment
variables are not copied to disk, and writes through a private temporary
file so a settings file is never partially written or briefly readable
by other users.
2026-08-26 11:53:47 +02:00
Donncha Ó Cearbhaill 4e031cd43f Move development documentation under docs/development
The Development section holds more than one page and will grow further,
so give it its own directory. development.md becomes development/index.md,
which keeps its published URL, and the custom CLI command page moves
alongside it.

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

No files matched your search

+5 -1
View File
@@ -73,7 +73,11 @@ rather than in MVT's own `config.yaml`.
### Commands on `mvt`
A MVT plugin command can also add sub-commands to the base `mvt` command. This can be used for commands which are not tied to a particular forensic platform:
The `mvt` command hosts what belongs to neither platform: `version`,
`completion`, `plugins` and `download-iocs`. A plugin command which is not
about the acquisition of one platform, such as one which configures the plugin
or synchronizes the indicators it uses, belongs there too, in the
`mvt.cli_plugins` group:
```toml
[project.entry-points."mvt.cli_plugins"]
+1 -58
View File
@@ -78,9 +78,7 @@ MVT logs a warning. The nine pairs are:
`check-iocs` re-checks stored results rather than an acquisition. It matches
every `<slug>.json` file in the results folder to the module with that slug.
It then runs that module's `check_indicators()` again. A module which
implements `check_indicators()` is included in `check-iocs` for its platform.
It does not need to declare the `check-iocs` pair.
It then runs that module's `check_indicators()` again.
### Writing a module
@@ -178,61 +176,6 @@ not, MVT skips the module with a warning.
They are the same records it writes to `<slug>.json`. Typed results per module
are planned.
### Replacing a built-in module
A custom module which extends a built-in one runs alongside it, and when the
two share a slug they write to the same results file. MVT warns about that
collision, naming both modules, where each came from and the file the later one
overwrites, but it runs them both. Set `replaces` to the class the module
supersedes to take that module's place instead: when both are available to a
command, the named module is dropped from the run.
```python
from mvt.ios.modules.backup import Manifest as BuiltinManifest
class Manifest(BuiltinManifest):
supported_commands = (("ios", "check-backup"),)
replaces = BuiltinManifest
```
Keeping the class name of the replaced module, as above, keeps the name
`--module` selects the module by and the slug its results file is named after.
A replacement with a different class name writes to a file named after its own
slug, unless it sets `slug` to the slug of the module it replaces, and
`--module` still selects it by the name of the replaced module, which MVT logs.
Taking over the slug of a replaced module is not a collision and is not warned
about, because that module is no longer part of the run.
Modules which depend on the replaced module receive the replacement instead, so
`get_dependency_results(BuiltinManifest)` returns the replacement's results. A
module must not depend on the module it replaces: that module does not run, so
the replacement has to produce the data itself, and MVT logs a warning when a
module declares both.
A replacement takes on the obligations of the module it replaces. If it
declares a dependency the command does not provide, it is skipped like any
other module with an unavailable dependency, and the module it replaced stays
out of the run: neither of them produces results, and the modules depending on
the replaced module are skipped as well.
Subclassing the replaced module is not required, but a replacement which is not
a subclass is logged with a warning, because its results may not be what the
modules depending on the replaced module expect. Naming a module the command
does not run has no effect, and modules which replace each other in a cycle all
keep running and replace nothing. Every applied substitution is logged, so it
is recorded in `command.log` when the command runs with an `--output` folder.
Every command resolves replacements on its own. `check-iocs` matches stored
results files against the slugs of the modules available for that command. A
replacement which subclasses the module it replaces inherits its
`check_indicators()`. It is then part of `check-iocs` for its platform and
re-checks the results file named after its slug. A replacement with no
`check_indicators()` is not part of `check-iocs`. The built-in module it
replaced re-checks the file. `check-iocs` matches `--module` on the class name
only. Pass a differently named replacement's own name there, not the name of
the module it replaces.
### Importing from MVT
Import from `mvt.plugin` if it has what you need. The names it exports are
@@ -191,14 +191,9 @@ class TombstoneCrashArtifact(AndroidArtifact):
def _load_key_value_line(
self, line: str, key: str, destination_key: str, tombstone: dict
) -> bool:
# The caller matched the key as a bare prefix, so a longer word starting
# with it arrives here: `Caused by: …` inside an abort message reaches
# the `Cause` key. That is a different line, not a broken file — say so
# by declining it, and let the remaining keys have their turn. Raising
# here discarded the whole tombstone, crash and stack trace included.
line_key, separator, value = line.partition(":")
if not separator or line_key != key:
return False
line_key, value = line.split(":", 1)
if line_key != key:
raise ValueError(f"Expected key {key}, got {line_key}")
value_clean = value.strip().strip("'")
if destination_key == "uid":
+10 -25
View File
@@ -14,6 +14,7 @@ from mvt.common.cli_plugins import (
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.help import (
HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_ADB_REMOVED,
@@ -39,12 +40,17 @@ from mvt.common.help import (
HELP_MSG_VERSION,
HELP_MSG_VIRUS_TOTAL,
)
from mvt.common.logo import logo
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import init_logging, set_verbose_logging
# The commands import what they run only when they are invoked. This module is
# imported at every start of mvt-android, including by shell completion on
# every keystroke, so importing it must do no more than build the command tree:
# the forensic modules, the backup parsers and the update checks stay out of it.
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
from .cmd_check_backup import CmdAndroidCheckBackup
from .cmd_check_bugreport import CmdAndroidCheckBugreport
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from .command_modules import ANDROID_CHECK_IOCS_MODULES
from .modules.backup.helpers import cli_load_android_backup_password
init_logging()
log = logging.getLogger("mvt")
@@ -68,8 +74,6 @@ def _get_verbose(ctx):
def _load_custom_modules(load_module):
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
@@ -97,9 +101,6 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
ctx.obj["verbose"] = verbose
set_verbose_logging(verbose)
from mvt.common.logo import logo
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
@@ -163,8 +164,6 @@ def check_bugreport(
verbose,
bugreport_path,
):
from .cmd_check_bugreport import CmdAndroidCheckBugreport
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as bug reports are small.
@@ -234,9 +233,6 @@ def check_backup(
verbose,
backup_path,
):
from .cmd_check_backup import CmdAndroidCheckBackup
from .modules.backup.helpers import cli_load_android_backup_password
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
@@ -315,9 +311,6 @@ def check_androidqf(
verbose,
androidqf_path,
):
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
from .modules.backup.helpers import cli_load_android_backup_password
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
@@ -400,8 +393,6 @@ def check_intrusion_logs(
verbose,
logs_path,
):
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
@@ -455,10 +446,6 @@ def check_intrusion_logs(
@click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from .command_modules import ANDROID_CHECK_IOCS_MODULES
custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS(
target_path=folder,
@@ -485,8 +472,6 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
# ==============================================================================
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
def download_indicators():
from mvt.common.updates import IndicatorsUpdates
ioc_updates = IndicatorsUpdates()
ioc_updates.update()
+3 -1
View File
@@ -292,7 +292,9 @@ class CmdAndroidCheckAndroidQF(Command):
try:
cmd.from_ab(backup)
except InvalidAndroidBackup as exc:
self.log.warning("Skipping backup modules: %s", exc)
self.log.warning(
"Skipping backup modules as backup.ab is malformed: %s", exc
)
return False
cmd.run()
-4
View File
@@ -87,15 +87,11 @@ class CmdAndroidCheckBackup(Command):
if header["encryption"] != "none":
password = prompt_or_load_android_backup_password(log, self.module_options)
if not password:
if self.sub_command:
raise InvalidAndroidBackup("No backup password provided")
log.critical("No backup password provided.")
sys.exit(1)
try:
tardata = parse_backup_file(ab_file_bytes, password=password)
except InvalidBackupPassword:
if self.sub_command:
raise InvalidAndroidBackup("Invalid backup password")
log.critical("Invalid backup password")
sys.exit(1)
except AndroidBackupParsingError as exc:
+4 -2
View File
@@ -47,8 +47,10 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
"""Mobile Verification Toolkit.
The 'mvt-ios' and 'mvt-android' CLI commands are used to perform
forensic analysis on IOS and Android devices.
mvt-ios and mvt-android run the forensic analysis of an acquisition: each
provides the check-* commands of its platform. This command hosts what
belongs to neither platform; run it without a command to see the installed
version and the list of what it offers.
"""
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
+1 -6
View File
@@ -15,9 +15,7 @@ from typing import Iterable
import click
# This module is imported by every CLI at start-up, and by shell completion on
# every keystroke, so it must stay cheap: nothing here may import the module
# machinery (mvt.common.module_loader and what it pulls in).
from .module_loader import CUSTOM_COMMAND_MODULE_PREFIX
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
@@ -26,9 +24,6 @@ NEUTRAL_CLI_PLUGIN_GROUP = "mvt.cli_plugins"
MVT_CUSTOM_COMMANDS_ENV = "MVT_CUSTOM_COMMANDS"
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
# Prefix of the import name given to a command file loaded from a path. Shared
# with module_loader, which recognises such files when naming their loggers.
CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_"
log = logging.getLogger(__name__)
+16 -265
View File
@@ -72,10 +72,6 @@ class Command:
# down a password to decrypt a backup or flags which are need by some modules.
self.module_options = module_options if module_options else {}
# This dictionary maps the modules which were replaced by a module
# declaring `replaces` to the module which took their place.
self.module_replacements: dict[type[MVTModule], type[MVTModule]] = {}
# This list will contain all executed modules.
# We can use this to reference e.g. self.executed[0].results.
self.executed: list[MVTModule] = []
@@ -261,174 +257,7 @@ class Command:
if module not in deduplicated:
deduplicated.append(module)
available = self._apply_replacements(deduplicated)
self._warn_about_slug_collisions(available)
return available
def _warn_about_slug_collisions(self, modules: list[type[MVTModule]]) -> None:
"""Report modules writing their results to the same file.
Results are stored in a file named after the module slug, so two
modules sharing one silently overwrite each other. Replacements are
already resolved here, so a module deliberately taking over the slug
of the module it replaces is not reported: the module it replaced is
no longer part of the run.
"""
modules_by_slug: dict[str, type[MVTModule]] = {}
for module in modules:
slug = module.get_slug()
first = modules_by_slug.setdefault(slug, module)
if first is module:
continue
self.log.warning(
"Modules %s from %s and %s from %s both use the slug %s. If "
"both run, whichever runs last overwrites the results of the "
"other in %s.json.",
first.__name__,
get_module_origin(first).label,
module.__name__,
get_module_origin(module).label,
slug,
slug,
)
def _declared_replacements(
self, modules: list[type[MVTModule]]
) -> dict[type[MVTModule], type[MVTModule]]:
"""Return the replacements declared by the given modules."""
replacements: dict[type[MVTModule], type[MVTModule]] = {}
for module in modules:
replaced = module.replaces
if replaced is None or replaced is module:
continue
if not module.enabled:
# Replacing a module must not disable it, as a disabled
# replacement never runs in its place.
self.log.debug(
"Module %s is disabled and does not replace module %s.",
module.__name__,
replaced.__name__,
)
continue
if replaced not in modules:
# A module can support several commands while the module it
# replaces is only available in some of them.
self.log.debug(
"Module %s replaces module %s, which is not available "
"for the %s command.",
module.__name__,
replaced.__name__,
self.name,
)
continue
if replaced in replacements:
self.log.warning(
"Modules %s and %s both replace module %s. Both of them "
"will run, %s will not, and modules depending on %s will "
"use the results of %s. Replacements which share the slug "
"of %s overwrite each other's results file.",
replacements[replaced].__name__,
module.__name__,
replaced.__name__,
replaced.__name__,
replaced.__name__,
replacements[replaced].__name__,
replaced.__name__,
)
continue
replacements[replaced] = module
return replacements
def _drop_replacement_cycles(
self, replacements: dict[type[MVTModule], type[MVTModule]]
) -> None:
"""Undo the replacements between modules which replace each other."""
cyclic: set[type[MVTModule]] = set()
for replaced in replacements:
walked: list[type[MVTModule]] = []
module = replaced
while module in replacements and module not in cyclic:
if module in walked:
cyclic.update(walked[walked.index(module) :])
break
walked.append(module)
module = replacements[module]
if not cyclic:
return
self.log.warning(
"Modules %s replace each other in a cycle. None of them replaces "
"anything and all of them will run.",
", ".join(sorted(module.__name__ for module in cyclic)),
)
for module in cyclic:
replacements.pop(module, None)
def _log_replacement(
self,
replaced: type[MVTModule],
module: type[MVTModule],
replacement: type[MVTModule],
) -> None:
"""Report an applied replacement, and any problem with it."""
if not issubclass(module, replaced):
self.log.warning(
"Module %s replaces module %s but is not a subclass of it. "
"Its results might not be compatible with what modules "
"depending on %s expect.",
module.__name__,
replaced.__name__,
replaced.__name__,
)
if replaced in module.dependencies:
self.log.warning(
"Module %s depends on module %s, which it also replaces. The "
"dependency cannot be satisfied: a replacement has to produce "
"that data itself.",
module.__name__,
replaced.__name__,
)
self.log.info(
"Module %s from %s replaces module %s from %s.",
replacement.__name__,
get_module_origin(replacement).label,
replaced.__name__,
get_module_origin(replaced).label,
)
def _apply_replacements(
self, modules: list[type[MVTModule]]
) -> list[type[MVTModule]]:
"""Drop the modules superseded by a module declaring `replaces`."""
declared = self._declared_replacements(modules)
self._drop_replacement_cycles(declared)
replacements: dict[type[MVTModule], type[MVTModule]] = {}
for replaced, module in declared.items():
# Follow chains of replacements, so that a dependency on a
# replaced module always resolves to a module which is part of
# the run.
replacement = module
while replacement in declared:
replacement = declared[replacement]
# Only the replacements which are applied are reported, so that
# the record matches the modules which actually run.
self._log_replacement(replaced, module, replacement)
replacements[replaced] = replacement
self.module_replacements = replacements
return [module for module in modules if module not in replacements]
return deduplicated
def init(self) -> None:
raise NotImplementedError
@@ -488,80 +317,6 @@ class Command:
console.print("")
console.print(panel)
def _module_dependencies(
self, module: type[MVTModule]
) -> list[tuple[type[MVTModule], type[MVTModule]]]:
"""Return the (declared, resolved) dependencies of a module.
A dependency on a module which was replaced is resolved to the module
which took its place. A module which replaces one of its own
dependencies is not made to depend on itself, while a module which
declares itself as a dependency is left alone and still fails the
circular dependency check.
"""
dependencies = []
for dependency in module.dependencies:
resolved = self.module_replacements.get(dependency, dependency)
if resolved is module and dependency is not module:
continue
dependencies.append((dependency, resolved))
return dependencies
@staticmethod
def _dependency_name(
declared: type[MVTModule], resolved: type[MVTModule]
) -> str:
"""Return how a dependency is named in the messages about it.
A dependency is named as the module which declared it wrote it, and,
when that module was replaced, as the module which runs in its place.
"""
if declared is resolved:
return declared.__name__
return f"{resolved.__name__} (replacing module {declared.__name__})"
def _selected_modules(
self, modules: list[type[MVTModule]]
) -> Optional[list[type[MVTModule]]]:
"""Return the modules explicitly requested, or all the enabled ones.
Returns None when a module was requested by name and no module of
that name can be run.
"""
if not self.module_name:
return [module for module in modules if module.enabled]
selected = [
module for module in modules if module.__name__ == self.module_name
]
# A module replacing another one does not have to keep its name, so
# the name of a replaced module selects its replacement.
if not selected:
for replaced, replacement in self.module_replacements.items():
if replaced.__name__ != self.module_name or replacement in selected:
continue
self.log.info(
"Module %s was replaced by module %s, which is run "
"in its place.",
replaced.__name__,
replacement.__name__,
)
selected.append(replacement)
if not selected:
self.log.warning(
"No module named %s is available for the %s command. "
"No modules will be run.",
self.module_name,
self.name,
)
return None
return selected
def _skipped_modules(
self,
required: list[type[MVTModule]],
@@ -594,18 +349,15 @@ class Command:
if module in skipped:
continue
for declared, dependency in self._module_dependencies(module):
for dependency in module.dependencies:
if dependency not in module_indexes:
# A replaced dependency always resolves to a module of
# this command, so an unavailable one is always the
# module class the author declared.
skipped[module] = (module, declared)
skipped[module] = (module, dependency)
changed = True
self.log.warning(
"Module %s will be SKIPPED: it depends on module "
"%s, which is not available in this command.%s",
module.__name__,
declared.__name__,
dependency.__name__,
remainder,
)
break
@@ -620,7 +372,7 @@ class Command:
"module %s, itself skipped for depending on "
"unavailable module %s.%s",
module.__name__,
self._dependency_name(declared, dependency),
dependency.__name__,
missing.__name__,
remainder,
)
@@ -631,7 +383,7 @@ class Command:
"module %s, which depends on unavailable "
"module %s.%s",
module.__name__,
self._dependency_name(declared, dependency),
dependency.__name__,
root.__name__,
missing.__name__,
remainder,
@@ -645,9 +397,12 @@ class Command:
modules = self._available_modules()
module_indexes = {module: index for index, module in enumerate(modules)}
selected = self._selected_modules(modules)
if selected is None:
return None
if self.module_name:
selected = [
module for module in modules if module.__name__ == self.module_name
]
else:
selected = [module for module in modules if module.enabled]
required: set[type[MVTModule]] = set()
pending = list(selected)
@@ -656,7 +411,7 @@ class Command:
if module in required:
continue
required.add(module)
for _, dependency in self._module_dependencies(module):
for dependency in module.dependencies:
# Unavailable dependencies are reported by _skipped_modules().
if dependency in module_indexes:
pending.append(dependency)
@@ -669,14 +424,13 @@ class Command:
"Every selected module was skipped for an unavailable "
"dependency. No modules will be run."
)
return None
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
module: [] for module in runnable
}
indegree = {module: 0 for module in runnable}
for module in runnable:
for _, dependency in self._module_dependencies(module):
for dependency in module.dependencies:
if dependency not in indegree:
continue
dependents[dependency].append(module)
@@ -732,12 +486,9 @@ class Command:
module_options=self.module_options,
log=module_logger,
)
# Dependencies are keyed by the module class they declare, even
# when it was replaced, so that a module asking for the results
# of a replaced module receives those of its replacement.
m.dependency_modules = {
dependency: executed_by_type[resolved]
for dependency, resolved in self._module_dependencies(module)
dependency: executed_by_type[dependency]
for dependency in module.dependencies
}
if self.iocs.total_ioc_count:
-5
View File
@@ -46,11 +46,6 @@ class MVTModule:
slug: Optional[str] = None
dependencies: Sequence[type["MVTModule"]] = ()
supported_commands: Sequence[tuple[str, str]] = ()
# A custom module can name a module class it supersedes, usually a
# built-in one. When both are available to a command, the named module is
# dropped from the run and this module takes its place, including in the
# dependencies of any other module.
replaces: Optional[type["MVTModule"]] = None
def __init__(
self,
+3 -12
View File
@@ -18,7 +18,6 @@ from pathlib import Path
from types import ModuleType
from typing import Iterable, Optional
from .cli_plugins import CUSTOM_COMMAND_MODULE_PREFIX
from .module import MVTModule
from .version import MVT_VERSION
@@ -28,6 +27,8 @@ EXTERNAL_LOGGER_NAMESPACE = "mvt.ext"
PLUGIN_PACKAGE_PREFIX = "mvt_plugin_"
_ORIGIN_ATTRIBUTE = "_mvt_module_origin"
_PATH_MODULE_PREFIX = "_mvt_custom_module_"
# Shared with cli_plugins, which names a loaded command file this way.
CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_"
_LOADED_FILE_DIGEST = re.compile(r"_[0-9a-f]{16}$")
log = logging.getLogger(__name__)
@@ -401,14 +402,4 @@ def module_supports_command(
)
return False
pairs = {tuple(entry) for entry in supported_commands}
if (platform, command) in pairs:
return True
# A module which implements check_indicators() is re-checked by check-iocs
# for its platform. It does not need to declare the check-iocs pair.
return (
command == "check-iocs"
and platform in {entry[0] for entry in pairs if entry}
and module_class.check_indicators is not MVTModule.check_indicators
)
return (platform, command) in {tuple(entry) for entry in supported_commands}
+1 -4
View File
@@ -14,6 +14,7 @@ from dataclasses import asdict, is_dataclass
from typing import Any, Iterator, Union
from .log import MVTLogHandler
from mvt.common.config import settings
class CustomJSONEncoder(json.JSONEncoder):
@@ -271,10 +272,6 @@ def set_verbose_logging(verbose: bool = False):
def exec_or_profile(module, globals, locals):
"""Hook for profiling MVT modules"""
# Imported here so that the CLI modules, which import this one at start-up,
# do not load the settings (and pydantic) before a command runs.
from .config import settings
if settings.PROFILE:
cProfile.runctx(module, globals, locals)
else:
+9 -26
View File
@@ -14,7 +14,10 @@ from mvt.common.cli_plugins import (
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.logo import logo
from mvt.common.options import MutuallyExclusiveOption
from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import (
generate_hashes_from_path,
init_logging,
@@ -44,12 +47,13 @@ from mvt.common.help import (
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
)
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.password import prompt_password
# The commands import what they run only when they are invoked. This module is
# imported at every start of mvt-ios, including by shell completion on every
# keystroke, so importing it must do no more than build the command tree: the
# forensic modules, the backup decryption and the update checks stay out of it.
from .cmd_check_backup import CmdIOSCheckBackup
from .cmd_check_fs import CmdIOSCheckFS
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
from .decrypt import DecryptBackup
from .command_modules import IOS_CHECK_IOCS_MODULES
init_logging()
log = logging.getLogger("mvt")
@@ -75,8 +79,6 @@ def _get_verbose(ctx):
def _load_custom_modules(load_module):
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
@@ -104,9 +106,6 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
ctx.obj["verbose"] = verbose
set_verbose_logging(verbose)
from mvt.common.logo import logo
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
@@ -147,8 +146,6 @@ def version():
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
from .decrypt import DecryptBackup
backup = DecryptBackup(backup_path, destination)
if key_file:
@@ -212,8 +209,6 @@ def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
def extract_key(password, key_file, backup_path):
from .decrypt import DecryptBackup
backup = DecryptBackup(backup_path)
if password:
@@ -281,8 +276,6 @@ def check_backup(
verbose,
backup_path,
):
from .cmd_check_backup import CmdIOSCheckBackup
set_verbose_logging(verbose or _get_verbose(ctx))
module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module)
@@ -352,8 +345,6 @@ def check_fs(
verbose,
dump_path,
):
from .cmd_check_fs import CmdIOSCheckFS
set_verbose_logging(verbose or _get_verbose(ctx))
module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module)
@@ -422,8 +413,6 @@ def check_sysdiagnose(
verbose,
sysdiagnose_path,
):
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckSysdiagnose(
@@ -478,10 +467,6 @@ def check_sysdiagnose(
@click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from .command_modules import IOS_CHECK_IOCS_MODULES
custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS(
target_path=folder,
@@ -508,8 +493,6 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
# ==============================================================================
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
def download_iocs():
from mvt.common.updates import IndicatorsUpdates
ioc_updates = IndicatorsUpdates()
ioc_updates.update()
@@ -1,54 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""A `Caused by:` line must not discard the whole text tombstone.
Keys are matched as bare prefixes, so `Caused by: …` — an ordinary line inside
an abort message — reached the `Cause` key, failed the key comparison and
raised, which `Tombstones.run()` logged while dropping the entire crash record.
Seen on a 1.6 MB tombstone whose protobuf twin was zero bytes: the crash then
had no representation at all.
"""
import datetime
from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact
TOMBSTONE = b"""\
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'Xiaomi/vili_eea/vili:13/TKQ1.220829.002/V14.0.10.0:user/release-keys'
Revision: '0'
ABI: 'arm64'
Timestamp: 2023-08-24 14:54:47.999124034+0300
Process uptime: 12199s
Cmdline: com.example.game
pid: 8044, tid: 26222, name: UnityMain >>> com.example.game <<<
uid: 10235
signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
Abort message: 'No pending exception expected: java.lang.SecurityException: listen
at void android.os.Parcel.readException() (Parcel.java:2920)
Caused by: android.os.RemoteException: Remote stack trace:
\tat com.android.server.TelephonyRegistry.listen(TelephonyRegistry.java:1096)
"""
WITH_CAUSE = TOMBSTONE + b"Cause: null pointer dereference\n"
class TestTombstoneCausedBy:
def _parse(self, content):
artifact = TombstoneCrashArtifact()
artifact.results = []
artifact.parse("tombstone_23", datetime.datetime(2023, 8, 24), content)
return artifact.results
def test_caused_by_line_does_not_discard_the_tombstone(self):
results = self._parse(TOMBSTONE)
assert len(results) == 1
assert results[0]["pid"] == 8044
assert results[0]["process_name"] == "UnityMain"
assert results[0]["uid"] == 10235
def test_the_real_cause_key_is_still_parsed(self):
results = self._parse(WITH_CAUSE)
assert results[0]["cause"] == "null pointer dereference"
@@ -1,39 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""An encrypted backup.ab must not take the whole check-androidqf run with it.
`CmdAndroidCheckBackup.from_ab()` already raises `InvalidAndroidBackup` instead
of exiting when it runs as a sub-command (`check-androidqf` catches that and
skips the backup modules), for a wrong file format and for a parse error. The
password branches used to call `sys.exit(1)` unconditionally, which ends the
parent run inside `finish()` — before the intrusion-logs command and before the
timeline, alerts, urls, info and run-manifest are written.
"""
import pytest
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup, InvalidAndroidBackup
ENCRYPTED_AB_HEADER = b"ANDROID BACKUP\n5\n0\nAES-256\n" + b"\x00" * 64
class TestCheckBackupOptionalFailure:
def _cmd(self, tmp_path, sub_command):
return CmdAndroidCheckBackup(
target_path=None,
results_path=str(tmp_path),
module_options={"interactive": False},
sub_command=sub_command,
)
def test_missing_password_raises_when_nested(self, tmp_path):
cmd = self._cmd(tmp_path, sub_command=True)
with pytest.raises(InvalidAndroidBackup):
cmd.from_ab(ENCRYPTED_AB_HEADER)
def test_missing_password_still_exits_on_its_own_command(self, tmp_path):
cmd = self._cmd(tmp_path, sub_command=False)
with pytest.raises(SystemExit):
cmd.from_ab(ENCRYPTED_AB_HEADER)
+12 -98
View File
@@ -15,7 +15,6 @@ from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.module import MVTModule
from mvt.ios.cli import cli as ios_cli
from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES
from mvt.ios.modules.backup.manifest import Manifest
# Keep the banner of the group callback from checking for updates online.
OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"]
@@ -40,69 +39,34 @@ class CustomResultsModule(MVTModule):
self.checked.append(list(self.results))
class BackupCheckerModule(MVTModule):
"""An iOS module which implements check_indicators() without declaring check-iocs."""
slug = "backup_checker"
supported_commands = (("ios", "check-backup"),)
checked: list = []
def run(self) -> None:
pass
def check_indicators(self) -> None:
self.checked.append(list(self.results))
class BugReportCheckerModule(MVTModule):
"""The same for Android."""
slug = "bugreport_checker"
supported_commands = (("android", "check-bugreport"),)
checked: list = []
def run(self) -> None:
pass
def check_indicators(self) -> None:
self.checked.append(list(self.results))
class BackupOnlyModule(MVTModule):
"""A custom module which does not implement check_indicators()."""
"""A custom module which does not declare check-iocs."""
slug = "backup_only"
supported_commands = (("ios", "check-backup"),)
def run(self) -> None:
pass
def check_indicators(self) -> None:
raise AssertionError("must not be re-checked")
@pytest.mark.parametrize(
"platform, builtin_modules, checker_module",
[
("ios", IOS_CHECK_IOCS_MODULES, BackupCheckerModule),
("android", ANDROID_CHECK_IOCS_MODULES, BugReportCheckerModule),
],
"platform, builtin_modules",
[("ios", IOS_CHECK_IOCS_MODULES), ("android", ANDROID_CHECK_IOCS_MODULES)],
)
def test_check_iocs_rechecks_the_stored_results_of_custom_modules(
platform, builtin_modules, checker_module, tmp_path, caplog
platform, builtin_modules, tmp_path, caplog
):
# check-iocs matches every <slug>.json in the results folder to the module
# with that slug, custom modules included, and runs its check_indicators()
# again over the stored results.
results = [{"domain": "example.org"}]
(tmp_path / "custom_results.json").write_text(json.dumps(results))
(tmp_path / f"{checker_module.slug}.json").write_text(json.dumps(results))
(tmp_path / "backup_only.json").write_text(json.dumps(results))
CustomResultsModule.checked.clear()
checker_module.checked.clear()
cmd = CmdCheckIOCS(
target_path=str(tmp_path),
custom_modules=[CustomResultsModule, checker_module, BackupOnlyModule],
custom_modules=[CustomResultsModule, BackupOnlyModule],
platform=platform,
)
cmd.modules = builtin_modules
@@ -110,79 +74,29 @@ def test_check_iocs_rechecks_the_stored_results_of_custom_modules(
with caplog.at_level(logging.INFO):
cmd.run()
# A module which declares the check-iocs pair is re-checked.
assert CustomResultsModule.checked == [results]
assert (
'Loading results from "custom_results.json" with module CustomResultsModule'
in caplog.text
)
# So is a module which only implements check_indicators().
assert checker_module.checked == [results]
# A module which does neither is not part of check-iocs.
# A module declaring only check-backup is not part of check-iocs.
assert "backup_only.json" not in caplog.text
@pytest.mark.parametrize(
"platform, builtin_modules, listed, not_listed",
[
(
"ios",
IOS_CHECK_IOCS_MODULES,
"BackupCheckerModule",
"BugReportCheckerModule",
),
(
"android",
ANDROID_CHECK_IOCS_MODULES,
"BugReportCheckerModule",
"BackupCheckerModule",
),
],
)
def test_check_iocs_lists_the_custom_modules_it_runs(
platform, builtin_modules, listed, not_listed, caplog
):
def test_check_iocs_lists_custom_modules_declaring_the_command(caplog):
cmd = CmdCheckIOCS(
custom_modules=[
CustomResultsModule,
BackupCheckerModule,
BugReportCheckerModule,
BackupOnlyModule,
],
platform=platform,
custom_modules=[CustomResultsModule, BackupOnlyModule],
platform="ios",
)
cmd.modules = builtin_modules
cmd.modules = IOS_CHECK_IOCS_MODULES
with caplog.at_level(logging.INFO):
cmd.list_modules()
assert "CustomResultsModule" in caplog.text
# The module which implements check_indicators() for this platform is listed.
assert listed in caplog.text
# The one for the other platform is not, and neither is BackupOnlyModule.
assert not_listed not in caplog.text
assert "BackupOnlyModule" not in caplog.text
class ReplacementManifest(Manifest):
"""A replacement for a built-in module which does not declare check-iocs."""
supported_commands = (("ios", "check-backup"),)
replaces = Manifest
def test_check_iocs_uses_a_replacement_of_a_built_in_module():
# A replacement which subclasses a built-in module inherits its
# check_indicators(). check-iocs then runs it in place of that module.
cmd = CmdCheckIOCS(custom_modules=[ReplacementManifest], platform="ios")
cmd.modules = IOS_CHECK_IOCS_MODULES
available = cmd._available_modules()
assert ReplacementManifest in available
assert Manifest not in available
LOADED_MODULE = '''
from mvt.common.module import MVTModule
-490
View File
@@ -68,123 +68,6 @@ class CustomDependsOnBuiltin(RecordingModule):
dependencies = (FirstModule,)
class ReplacementModule(FirstModule):
supported_commands = (("ios", "check-backup"),)
replaces = FirstModule
def run(self):
super().run()
self.results = ["replacement"]
class OtherReplacementModule(FirstModule):
supported_commands = (("ios", "check-backup"),)
replaces = FirstModule
class UnrelatedReplacementModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
replaces = IndependentModule
class ReplacementOfReplacementModule(ReplacementModule):
supported_commands = (("ios", "check-backup"),)
replaces = ReplacementModule
def run(self):
super().run()
self.results = ["replacement of replacement"]
class ReplacesOwnDependencyModule(FirstModule):
supported_commands = (("ios", "check-backup"),)
replaces = FirstModule
dependencies = (FirstModule,)
class DisabledReplacementModule(FirstModule):
supported_commands = (("ios", "check-backup"),)
replaces = FirstModule
enabled = False
class MutualReplacementOne(RecordingModule):
supported_commands = (("ios", "check-backup"),)
class MutualReplacementTwo(RecordingModule):
supported_commands = (("ios", "check-backup"),)
replaces = MutualReplacementOne
MutualReplacementOne.replaces = MutualReplacementTwo
class CycleOneModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
class CycleTwoModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
replaces = CycleOneModule
class CycleThreeModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
replaces = CycleTwoModule
CycleOneModule.replaces = CycleThreeModule
class UnavailableDependencyModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
class ReplacementMissingDependency(FirstModule):
supported_commands = (("ios", "check-backup"),)
replaces = FirstModule
dependencies = (UnavailableDependencyModule,)
class SharedSlugModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
slug = "shared_slug"
class OtherSharedSlugModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
slug = "shared_slug"
class ReplacementKeepingTheSlug(FirstModule):
supported_commands = (("ios", "check-backup"),)
replaces = FirstModule
slug = "first_module"
class SameNameReplacementModule(FirstModule):
supported_commands = (("ios", "check-backup"),)
replaces = FirstModule
def run(self):
super().run()
self.results = ["replacement"]
# Modules replacing a built-in one usually keep its class name, which is the
# name `--module` matches on.
SameNameReplacementModule.__name__ = "FirstModule"
def logged_substitutions(caplog) -> list[str]:
return [
record.getMessage()
for record in caplog.records
if record.levelno == logging.INFO and "replaces module" in record.getMessage()
]
class RecordingCommand(Command):
def init(self):
self.initialized = True
@@ -420,376 +303,3 @@ class TestCommand:
cmd.run()
assert RecordingModule.run_order == ["FirstModule", "CustomDependsOnBuiltin"]
def test_custom_module_replaces_builtin(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule, IndependentModule]
cmd.custom_modules = [ReplacementModule]
with caplog.at_level(logging.INFO):
cmd.run()
assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"]
assert cmd.module_replacements == {FirstModule: ReplacementModule}
assert (
"Module ReplacementModule from" in caplog.text
and "replaces module FirstModule" in caplog.text
)
def test_modules_without_replaces_all_run(self):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule]
cmd.custom_modules = [CustomIOSBackupModule]
cmd.run()
assert RecordingModule.run_order == ["FirstModule", "CustomIOSBackupModule"]
assert cmd.module_replacements == {}
def test_unavailable_replaced_module_is_ignored(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [IndependentModule]
cmd.custom_modules = [ReplacementModule]
with caplog.at_level(logging.INFO):
cmd.run()
assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"]
assert cmd.module_replacements == {}
assert "replaces module FirstModule" not in caplog.text
def test_dependencies_are_resolved_to_the_replacement(self):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [SecondModule, FirstModule]
cmd.custom_modules = [ReplacementModule]
cmd.run()
assert RecordingModule.run_order == ["ReplacementModule", "SecondModule"]
second = next(
module for module in cmd.executed if isinstance(module, SecondModule)
)
assert isinstance(second.dependency_modules[FirstModule], ReplacementModule)
assert second.results == ["replacement", "second"]
def test_replacement_with_unavailable_dependency_skips_its_dependents(
self, caplog
):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule, SecondModule, IndependentModule]
cmd.custom_modules = [ReplacementMissingDependency]
with caplog.at_level(logging.INFO):
cmd.run()
# The replacement cannot run, and the module it replaced was dropped
# from the run by the replacement, so neither of them produces
# results and the module depending on the replaced one is skipped.
assert RecordingModule.run_order == ["IndependentModule"]
assert (
"Module ReplacementMissingDependency will be SKIPPED: it depends "
"on module UnavailableDependencyModule, which is not available in "
"this command." in caplog.text
)
# The skipped dependent is told which module it actually depends on,
# and the module class its author declared.
assert (
"Module SecondModule will be SKIPPED: it depends on module "
"ReplacementMissingDependency (replacing module FirstModule), "
"itself skipped for depending on unavailable module "
"UnavailableDependencyModule." in caplog.text
)
def test_selected_replacement_with_unavailable_dependency_runs_nothing(
self, caplog, tmp_path
):
cmd = RecordingCommand(module_name="FirstModule", results_path=str(tmp_path))
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule, IndependentModule]
cmd.custom_modules = [ReplacementMissingDependency]
with caplog.at_level(logging.INFO):
cmd.run()
assert RecordingModule.run_order == []
assert not hasattr(cmd, "initialized")
assert (
"Module FirstModule was replaced by module "
"ReplacementMissingDependency, which is run in its place."
in caplog.text
)
assert "Module ReplacementMissingDependency will be SKIPPED" in caplog.text
assert "No modules will be run" in caplog.text
# Nothing else was selected, so the warnings must not promise that the
# analysis continues right before saying that it does not.
assert "The rest of the analysis will still run" not in caplog.text
# No module ran, so no results were stored next to the command log.
assert [path.name for path in tmp_path.iterdir()] == ["command.log"]
def test_chained_replacements_are_resolved(self):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [SecondModule, FirstModule]
cmd.custom_modules = [ReplacementModule, ReplacementOfReplacementModule]
cmd.run()
assert RecordingModule.run_order == [
"ReplacementOfReplacementModule",
"SecondModule",
]
second = next(
module for module in cmd.executed if isinstance(module, SecondModule)
)
assert second.results == ["replacement of replacement", "second"]
def test_replacement_which_is_not_a_subclass_warns(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [IndependentModule]
cmd.custom_modules = [UnrelatedReplacementModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == ["UnrelatedReplacementModule"]
assert "is not a subclass of it" in caplog.text
def test_multiple_modules_replacing_the_same_module_warn(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule]
cmd.custom_modules = [ReplacementModule, OtherReplacementModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == [
"ReplacementModule",
"OtherReplacementModule",
]
assert cmd.module_replacements == {FirstModule: ReplacementModule}
assert (
"Modules ReplacementModule and OtherReplacementModule both replace "
"module FirstModule. Both of them will run, FirstModule will not, "
"and modules depending on FirstModule will use the results of "
"ReplacementModule." in caplog.text
)
assert "overwrite each other's results file" in caplog.text
def test_module_replacing_its_own_dependency_runs(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule]
cmd.custom_modules = [ReplacesOwnDependencyModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == ["ReplacesOwnDependencyModule"]
assert (
"Module ReplacesOwnDependencyModule depends on module FirstModule, "
"which it also replaces" in caplog.text
)
def test_literal_self_dependency_is_still_circular(self, caplog):
class SelfDependentModule(RecordingModule):
pass
SelfDependentModule.dependencies = (SelfDependentModule,)
cmd = RecordingCommand()
cmd.modules = [SelfDependentModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == []
assert not hasattr(cmd, "initialized")
assert "Circular module dependency detected" in caplog.text
def test_disabled_replacement_keeps_the_replaced_module(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule]
cmd.custom_modules = [DisabledReplacementModule]
with caplog.at_level(logging.INFO):
cmd.run()
assert RecordingModule.run_order == ["FirstModule"]
assert cmd.module_replacements == {}
assert logged_substitutions(caplog) == []
def test_modules_replacing_each_other_all_run(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.custom_modules = [MutualReplacementOne, MutualReplacementTwo]
with caplog.at_level(logging.INFO):
cmd.run()
assert RecordingModule.run_order == [
"MutualReplacementOne",
"MutualReplacementTwo",
]
assert cmd.module_replacements == {}
assert (
"Modules MutualReplacementOne, MutualReplacementTwo replace each "
"other in a cycle" in caplog.text
)
assert logged_substitutions(caplog) == []
def test_replacement_cycle_of_three_modules_all_run(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.custom_modules = [CycleOneModule, CycleTwoModule, CycleThreeModule]
with caplog.at_level(logging.INFO):
cmd.run()
assert RecordingModule.run_order == [
"CycleOneModule",
"CycleTwoModule",
"CycleThreeModule",
]
assert cmd.module_replacements == {}
assert (
"Modules CycleOneModule, CycleThreeModule, CycleTwoModule replace "
"each other in a cycle" in caplog.text
)
assert logged_substitutions(caplog) == []
def test_selected_replaced_module_name_runs_the_replacement(self, caplog):
cmd = RecordingCommand(module_name="FirstModule")
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule]
cmd.custom_modules = [ReplacementModule]
with caplog.at_level(logging.INFO):
cmd.run()
assert RecordingModule.run_order == ["ReplacementModule"]
assert (
"Module FirstModule was replaced by module ReplacementModule"
in caplog.text
)
def test_unknown_selected_module_warns_and_stops(self, caplog):
cmd = RecordingCommand(module_name="NoSuchModule")
cmd.name = "check-backup"
cmd.modules = [FirstModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == []
assert not hasattr(cmd, "initialized")
assert (
"No module named NoSuchModule is available for the check-backup "
"command" in caplog.text
)
def test_selected_module_name_matches_the_replacement(self):
cmd = RecordingCommand(module_name="FirstModule")
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule]
cmd.custom_modules = [SameNameReplacementModule]
cmd.run()
assert len(cmd.executed) == 1
assert isinstance(cmd.executed[0], SameNameReplacementModule)
assert cmd.executed[0].results == ["replacement"]
def test_list_modules_reflects_replacements(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule, IndependentModule]
cmd.custom_modules = [ReplacementModule]
with caplog.at_level(logging.INFO):
cmd.list_modules()
listed = [
record.getMessage()
for record in caplog.records
if "Modules from" in record.getMessage()
]
assert any("ReplacementModule" in message for message in listed)
assert not any("FirstModule" in message for message in listed)
def test_modules_sharing_a_slug_are_reported(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.custom_modules = [SharedSlugModule, OtherSharedSlugModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == [
"SharedSlugModule",
"OtherSharedSlugModule",
]
collisions = [
record.getMessage()
for record in caplog.records
if "both use the slug" in record.getMessage()
]
assert len(collisions) == 1
assert "Modules SharedSlugModule from" in collisions[0]
assert "and OtherSharedSlugModule from" in collisions[0]
assert "both use the slug shared_slug" in collisions[0]
assert "overwrites the results of the other in shared_slug.json" in (
collisions[0]
)
def test_replacement_keeping_the_replaced_slug_is_not_reported(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule]
cmd.custom_modules = [ReplacementKeepingTheSlug]
with caplog.at_level(logging.WARNING):
cmd.run()
# The replaced module is no longer part of the run, so taking over its
# slug is what the replacement is for, not a collision.
assert RecordingModule.run_order == ["ReplacementKeepingTheSlug"]
assert ReplacementKeepingTheSlug.get_slug() == FirstModule.get_slug()
assert "both use the slug" not in caplog.text
def test_modules_with_distinct_slugs_are_not_reported(self, caplog):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule, IndependentModule]
cmd.custom_modules = [CustomIOSBackupModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert "both use the slug" not in caplog.text
+1 -1
View File
@@ -155,7 +155,7 @@ class TestCheckAndroidqfCommand:
result = runner.invoke(check_androidqf, [str(path)])
assert result.exit_code == 0
assert "Skipping backup modules: Invalid backup format" in caplog.text
assert "Skipping backup modules as backup.ab is malformed" in caplog.text
assert not any(
record.levelname in {"CRITICAL", "FATAL"} for record in caplog.records
)
+1
View File
@@ -29,6 +29,7 @@ class TestMvtCommand:
assert result.exit_code == 0
assert "mvt-ios" in result.output
assert "mvt-android" in result.output
assert "check-*" in result.output
def test_version_prints_the_installed_version(self):
result = CliRunner().invoke(cli, [*OFFLINE, "version"])
-28
View File
@@ -1,28 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import pytest
from .plugin_fixtures import run_isolated_python
# Importing a platform CLI must only build its command tree: the console
# scripts import it before Click can answer a shell completion request, which
# the completion scripts make on every keystroke. Every command imports what
# it runs when it is invoked. Each of these costs tens of milliseconds to
# import and is the sign that a command implementation is imported too early.
HEAVY_MODULES = ("pydantic", "requests", "Crypto", "mvt.common.module")
@pytest.mark.parametrize("cli_module", ("mvt.ios.cli", "mvt.android.cli"))
def test_importing_a_cli_does_not_import_the_module_machinery(cli_module, tmp_path):
result = run_isolated_python(
"import sys\n"
f"import {cli_module}\n"
f"print(','.join(name for name in {HEAVY_MODULES!r} if name in sys.modules))\n",
home=tmp_path / "home",
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "", f"{cli_module} imported {result.stdout.strip()}"