mirror of
https://github.com/mvt-project/mvt.git
synced 2026-09-03 00:21:07 +02:00
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.
This commit is contained in:
@@ -58,7 +58,7 @@ For alternative installation options and known issues, please refer to the [docu
|
||||
|
||||
## Usage
|
||||
|
||||
MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
|
||||
MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version` and `download-iocs` (both remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
|
||||
|
||||
### Shell completion
|
||||
|
||||
@@ -82,8 +82,8 @@ Module-running `check-*` commands can load custom Python modules with
|
||||
[development documentation](https://docs.mvt.re/en/latest/development/) for
|
||||
details.
|
||||
|
||||
Users can also add top-level commands to `mvt-ios` and `mvt-android` from
|
||||
installed Python packages or local files and folders. See the
|
||||
Users can also add top-level commands to `mvt`, `mvt-ios` and `mvt-android`
|
||||
from installed Python packages or local files and folders. See the
|
||||
[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
|
||||
for the plugin entry points and `--load-command` interface.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Custom CLI Commands
|
||||
|
||||
MVT can load additional top-level commands into `mvt-ios` and `mvt-android`.
|
||||
MVT can load additional top-level commands into `mvt`, `mvt-ios` and
|
||||
`mvt-android`. A command package chooses which of the three each of its
|
||||
commands is added to.
|
||||
Custom commands are different from [custom forensic modules](index.md#custom-modules):
|
||||
commands add new CLI operations, while modules add analysis steps to existing
|
||||
`check-*` commands.
|
||||
@@ -13,8 +15,8 @@ commands add new CLI operations, while modules add analysis steps to existing
|
||||
|
||||
## Install a Command Package
|
||||
|
||||
Python packages can register a Click command or group for either MVT CLI. A
|
||||
minimal package can expose this command from `my_mvt_plugin.py`:
|
||||
Python packages can register a Click command or group on one or more of the MVT CLIs.
|
||||
A minimal package can expose this command from `my_mvt_plugin.py`:
|
||||
|
||||
```python
|
||||
import click
|
||||
@@ -38,9 +40,13 @@ summarize = "my_mvt_plugin:summarize"
|
||||
summarize = "my_mvt_plugin:summarize"
|
||||
```
|
||||
|
||||
Use only the iOS or Android group if the command is platform-specific. After
|
||||
installing the package in the same environment as MVT, it appears directly in
|
||||
the appropriate CLI:
|
||||
Each entry-point group adds the command to one CLI: `mvt.ios.cli_plugins` to
|
||||
`mvt-ios`, `mvt.android.cli_plugins` to `mvt-android` and `mvt.cli_plugins` to
|
||||
`mvt`. Register the command in the group of every CLI which should offer it: a
|
||||
platform-specific command belongs in one platform group, and a command which
|
||||
handles acquisitions of both platforms, as above, in both. After installing the
|
||||
package in the same environment as MVT, the command appears directly in those
|
||||
CLIs:
|
||||
|
||||
```bash
|
||||
mvt-ios summarize ./ios-backup
|
||||
@@ -60,6 +66,24 @@ Command packages that need their own settings, such as an API key, should store
|
||||
them in a namespaced [plugin configuration file](plugin_configuration.md)
|
||||
rather than in MVT's own `config.yaml`.
|
||||
|
||||
### Commands on `mvt`
|
||||
|
||||
The `mvt` command hosts what belongs to neither platform: `version` 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"]
|
||||
my-plugin = "my_mvt_plugin:my_plugin"
|
||||
```
|
||||
|
||||
Commands in this group are added to `mvt` only, so this one is invoked as
|
||||
`mvt my-plugin`. A command on `mvt` has nothing but its name to say which
|
||||
plugin it belongs to, so name it after the plugin, and make it a Click group
|
||||
when the plugin has several operations to offer, such as
|
||||
`mvt my-plugin configure`.
|
||||
|
||||
## Load a Command File
|
||||
|
||||
For local commands that are not packaged, create a Python file that exports one
|
||||
@@ -86,10 +110,12 @@ mvt-ios --load-command ./case_summary.py case-summary ./ios-backup
|
||||
non-hidden top-level `*.py` files in sorted order and skips `__init__.py`.
|
||||
Every loaded file must export one `cli` object.
|
||||
|
||||
To load a file or folder on every invocation, set the platform-specific
|
||||
environment variable:
|
||||
To load a file or folder on every invocation, set the environment variable of
|
||||
the CLI the commands belong on. Like the entry-point groups, each variable adds
|
||||
its commands to one CLI only:
|
||||
|
||||
```bash
|
||||
export MVT_CUSTOM_COMMANDS=./commands
|
||||
export MVT_IOS_CUSTOM_COMMANDS=./ios_commands
|
||||
export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands
|
||||
```
|
||||
@@ -97,9 +123,11 @@ export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands
|
||||
## Naming and Errors
|
||||
|
||||
Built-in MVT commands cannot be replaced. External command names must also be
|
||||
unique; when installed packages or environment paths collide, MVT keeps the
|
||||
first command and logs a warning. A collision from an explicit
|
||||
`--load-command` is a usage error.
|
||||
unique on each CLI; when installed packages or environment paths collide, MVT
|
||||
keeps the first command and logs a warning. The environment path of a CLI is
|
||||
registered before its installed packages, so a command loaded from there wins a
|
||||
collision with a package. A collision from an explicit `--load-command` is a
|
||||
usage error.
|
||||
|
||||
A package entry point or environment command that cannot be imported appears
|
||||
as a marked broken command without preventing other MVT commands from working.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ Mobile Verification Toolkit (MVT) is a tool to facilitate the [consensual forens
|
||||
It has been developed and released by the [Amnesty International Security Lab](https://securitylab.amnesty.org) in July 2021 in the context of the [Pegasus Project](https://forbiddenstories.org/about-the-pegasus-project/) along with [a technical forensic methodology](https://www.amnesty.org/en/latest/research/2021/07/forensic-methodology-report-how-to-catch-nso-groups-pegasus/). It continues to be maintained by Amnesty International and other contributors.
|
||||
|
||||
|
||||
In this documentation you will find instructions on how to install and run the `mvt-ios` and `mvt-android` commands, and guidance on how to interpret the extracted results.
|
||||
In this documentation you will find instructions on how to install and run the `mvt-ios`, `mvt-android` and `mvt` commands, and guidance on how to interpret the extracted results.
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
+3
-3
@@ -64,7 +64,7 @@ It is recommended to try installing and running MVT from [Windows Subsystem Linu
|
||||
pipx install mvt
|
||||
```
|
||||
|
||||
You now should have the `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window.
|
||||
You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window.
|
||||
|
||||
### Installing from PyPI directly into a virtual environment
|
||||
You can use `pipenv`, `poetry` etc. for your virtual environment, but the provided example is with the built-in `venv` tool:
|
||||
@@ -84,7 +84,7 @@ source env/bin/activate
|
||||
pip install mvt
|
||||
```
|
||||
|
||||
The `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active.
|
||||
The `mvt`, `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active.
|
||||
|
||||
### Installing from git source with pipx
|
||||
If you want to have the latest features in development, you can install MVT directly from the source code in git.
|
||||
@@ -93,7 +93,7 @@ If you want to have the latest features in development, you can install MVT dire
|
||||
pipx install --force git+https://github.com/mvt-project/mvt.git
|
||||
```
|
||||
|
||||
You now should have the `mvt-ios` and `mvt-android` utilities installed.
|
||||
You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed.
|
||||
|
||||
**Notes:**
|
||||
1. The `--force` flag is necessary to force the reinstallation of the package.
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis
|
||||
- [This repository](https://github.com/Te-k/stalkerware-indicators) contains IOCs for Android stalkerware including [a STIX MVT-compatible file](https://raw.githubusercontent.com/Te-k/stalkerware-indicators/master/generated/stalkerware.stix2).
|
||||
- We are also maintaining [a list of IOCs](https://github.com/mvt-project/mvt-indicators) in STIX format from public spyware campaigns.
|
||||
|
||||
You can automaticallly download the latest public indicator files with the command `mvt-ios download-iocs` or `mvt-android download-iocs`. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT.
|
||||
You can automatically download the latest public indicator files with the command `mvt download-iocs`. The per-platform forms `mvt-ios download-iocs` and `mvt-android download-iocs` do the same thing. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT.
|
||||
|
||||
Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs.
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ homepage = "https://docs.mvt.re/en/latest/"
|
||||
repository = "https://github.com/mvt-project/mvt"
|
||||
|
||||
[project.scripts]
|
||||
mvt = "mvt.cli:main"
|
||||
mvt-ios = "mvt.ios:main"
|
||||
mvt-android = "mvt.android:main"
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
|
||||
# ==============================================================================
|
||||
# Command: version
|
||||
# ==============================================================================
|
||||
@cli.command("version", help=HELP_MSG_VERSION)
|
||||
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
|
||||
def version():
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import click
|
||||
|
||||
from mvt.common.cli_plugins import (
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
load_cli_commands_option,
|
||||
register_cli_plugins,
|
||||
)
|
||||
from mvt.common.help import (
|
||||
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
HELP_MSG_DISABLE_UPDATE_CHECK,
|
||||
HELP_MSG_STIX2,
|
||||
HELP_MSG_VERSION,
|
||||
)
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
from mvt.common.utils import init_logging
|
||||
|
||||
init_logging()
|
||||
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Main
|
||||
# ==============================================================================
|
||||
@click.group(invoke_without_command=True)
|
||||
@load_cli_commands_option
|
||||
@click.option(
|
||||
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
|
||||
)
|
||||
@click.option(
|
||||
"--disable-indicator-update-check",
|
||||
is_flag=True,
|
||||
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
)
|
||||
@click.pass_context
|
||||
def cli(ctx, disable_update_check, disable_indicator_update_check):
|
||||
"""Mobile Verification Toolkit.
|
||||
|
||||
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
|
||||
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
)
|
||||
if ctx.invoked_subcommand is None:
|
||||
click.echo(ctx.get_help())
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: download-iocs
|
||||
# ==============================================================================
|
||||
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
|
||||
def download_iocs():
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
ioc_updates.update()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: version
|
||||
# ==============================================================================
|
||||
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
|
||||
def version():
|
||||
return
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Entry point of the mvt console script
|
||||
# ==============================================================================
|
||||
def main() -> None:
|
||||
"""Register the external commands and run the mvt CLI.
|
||||
|
||||
External commands are registered here rather than when this module is
|
||||
imported, so that importing MVT never runs third-party code and a plugin
|
||||
importing from MVT cannot re-enter a module that is still initializing.
|
||||
"""
|
||||
register_cli_plugins(
|
||||
cli,
|
||||
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
cli()
|
||||
@@ -17,6 +17,9 @@ import click
|
||||
|
||||
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
|
||||
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
|
||||
# Commands in this group are registered on the platform-neutral mvt command only.
|
||||
NEUTRAL_CLI_PLUGIN_GROUP = "mvt.cli_plugins"
|
||||
MVT_CUSTOM_COMMANDS_ENV = "MVT_CUSTOM_COMMANDS"
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
|
||||
|
||||
@@ -255,6 +258,16 @@ def register_cli_plugins(
|
||||
entry_point_group: str,
|
||||
environment_variable: str,
|
||||
) -> None:
|
||||
"""Register the external commands of one CLI on its group.
|
||||
|
||||
Each CLI has one entry-point group and one environment variable of its
|
||||
own, so a command package chooses the CLIs its commands are added to.
|
||||
|
||||
:param group: CLI group to register the external commands on.
|
||||
:param entry_point_group: Entry-point group of the CLI.
|
||||
:param environment_variable: Name of the environment variable holding a
|
||||
path to load commands from.
|
||||
"""
|
||||
environment_path = os.environ.get(environment_variable)
|
||||
if environment_path:
|
||||
register_cli_commands_from_path(group, environment_path)
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
|
||||
# ==============================================================================
|
||||
# Command: version
|
||||
# ==============================================================================
|
||||
@cli.command("version", help=HELP_MSG_VERSION)
|
||||
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
|
||||
def version():
|
||||
return
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ from types import SimpleNamespace
|
||||
import click
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.cli import cli as mvt_cli
|
||||
from mvt.common.cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
BrokenPluginCommand,
|
||||
load_cli_commands_option,
|
||||
register_cli_commands_from_path,
|
||||
@@ -14,6 +17,9 @@ from mvt.common.cli_plugins import (
|
||||
)
|
||||
|
||||
|
||||
# Keep the banner of the mvt group callback from checking for updates online.
|
||||
OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"]
|
||||
|
||||
COMMAND_TEMPLATE = """
|
||||
import click
|
||||
|
||||
@@ -345,7 +351,11 @@ def test_platform_entry_point_groups_and_environment_paths_are_separate(
|
||||
def entry_points(*, group):
|
||||
if group == IOS_CLI_PLUGIN_GROUP:
|
||||
return [_entry_point("ios-package", "ios_plugin:cli", ios_package)]
|
||||
return [_entry_point("android-package", "android_plugin:cli", android_package)]
|
||||
if group == ANDROID_CLI_PLUGIN_GROUP:
|
||||
return [
|
||||
_entry_point("android-package", "android_plugin:cli", android_package)
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
@@ -369,3 +379,187 @@ def test_platform_entry_point_groups_and_environment_paths_are_separate(
|
||||
|
||||
assert set(ios_group.commands) == {"ios-file", "ios-package"}
|
||||
assert set(android_group.commands) == {"android-file", "android-package"}
|
||||
|
||||
|
||||
def test_neutral_entry_point_group_is_not_registered_on_the_platform_clis(
|
||||
monkeypatch,
|
||||
):
|
||||
@click.command()
|
||||
def neutral_package():
|
||||
pass
|
||||
|
||||
def entry_points(*, group):
|
||||
if group == NEUTRAL_CLI_PLUGIN_GROUP:
|
||||
return [
|
||||
_entry_point("neutral-package", "neutral_plugin:cli", neutral_package)
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
entry_points,
|
||||
)
|
||||
ios_group = click.Group()
|
||||
android_group = click.Group()
|
||||
|
||||
register_cli_plugins(
|
||||
ios_group,
|
||||
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
||||
environment_variable="TEST_IOS_COMMANDS",
|
||||
)
|
||||
register_cli_plugins(
|
||||
android_group,
|
||||
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
|
||||
environment_variable="TEST_ANDROID_COMMANDS",
|
||||
)
|
||||
|
||||
assert not ios_group.commands
|
||||
assert not android_group.commands
|
||||
|
||||
|
||||
def test_environment_command_wins_collision_with_installed_command(
|
||||
tmp_path, monkeypatch, caplog
|
||||
):
|
||||
command_path = _write_command(
|
||||
tmp_path / "duplicate.py",
|
||||
"duplicate",
|
||||
message="environment command ran",
|
||||
)
|
||||
|
||||
@click.command()
|
||||
def installed_command():
|
||||
pass
|
||||
|
||||
def entry_points(*, group):
|
||||
if group == IOS_CLI_PLUGIN_GROUP:
|
||||
return [
|
||||
_entry_point(
|
||||
"duplicate",
|
||||
"ios_plugin:cli",
|
||||
installed_command,
|
||||
distribution="ios-plugin",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
entry_points,
|
||||
)
|
||||
monkeypatch.setenv("TEST_IOS_COMMANDS", str(command_path))
|
||||
group = click.Group()
|
||||
|
||||
register_cli_plugins(
|
||||
group,
|
||||
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
||||
environment_variable="TEST_IOS_COMMANDS",
|
||||
)
|
||||
|
||||
assert group.commands["duplicate"] is not installed_command
|
||||
result = CliRunner().invoke(group, ["duplicate"])
|
||||
assert result.exit_code == 0
|
||||
assert "environment command ran" in result.output
|
||||
assert "the command name is already registered" in caplog.text
|
||||
assert "ios-plugin 1.0 (ios_plugin:cli)" in caplog.text
|
||||
|
||||
|
||||
def test_the_mvt_cli_gets_the_neutral_commands_and_no_platform_command(
|
||||
monkeypatch, restore_cli_commands
|
||||
):
|
||||
@click.command()
|
||||
def shared_package():
|
||||
click.echo("shared command ran")
|
||||
|
||||
@click.command()
|
||||
def ios_package():
|
||||
pass
|
||||
|
||||
def entry_points(*, group):
|
||||
if group == NEUTRAL_CLI_PLUGIN_GROUP:
|
||||
return [_entry_point("shared-package", "shared_plugin:cli", shared_package)]
|
||||
if group == IOS_CLI_PLUGIN_GROUP:
|
||||
return [_entry_point("ios-package", "ios_plugin:cli", ios_package)]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
entry_points,
|
||||
)
|
||||
|
||||
register_cli_plugins(
|
||||
mvt_cli,
|
||||
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
|
||||
assert "ios-package" not in mvt_cli.commands
|
||||
result = CliRunner().invoke(mvt_cli, [*OFFLINE, "shared-package"])
|
||||
assert result.exit_code == 0
|
||||
assert "shared command ran" in result.output
|
||||
|
||||
|
||||
def test_builtin_mvt_command_wins_collision_with_neutral_command(
|
||||
monkeypatch, caplog, restore_cli_commands
|
||||
):
|
||||
@click.command()
|
||||
def neutral_version():
|
||||
pass
|
||||
|
||||
def entry_points(*, group):
|
||||
if group == NEUTRAL_CLI_PLUGIN_GROUP:
|
||||
return [
|
||||
_entry_point(
|
||||
"version",
|
||||
"neutral_plugin:cli",
|
||||
neutral_version,
|
||||
distribution="neutral-plugin",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
entry_points,
|
||||
)
|
||||
builtin_version = mvt_cli.commands["version"]
|
||||
|
||||
register_cli_plugins(
|
||||
mvt_cli,
|
||||
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
|
||||
assert mvt_cli.commands["version"] is builtin_version
|
||||
assert "the command name is already registered" in caplog.text
|
||||
assert "neutral-plugin 1.0 (neutral_plugin:cli)" in caplog.text
|
||||
|
||||
|
||||
def test_broken_neutral_plugin_does_not_break_the_mvt_cli(
|
||||
monkeypatch, restore_cli_commands
|
||||
):
|
||||
def entry_points(*, group):
|
||||
if group == NEUTRAL_CLI_PLUGIN_GROUP:
|
||||
return [
|
||||
_entry_point(
|
||||
"broken",
|
||||
"broken_plugin:cli",
|
||||
exception=RuntimeError("missing dependency"),
|
||||
distribution="broken-plugin",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
||||
entry_points,
|
||||
)
|
||||
|
||||
register_cli_plugins(
|
||||
mvt_cli,
|
||||
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
|
||||
assert isinstance(mvt_cli.commands["broken"], BrokenPluginCommand)
|
||||
result = CliRunner().invoke(mvt_cli, [*OFFLINE, "version"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -8,6 +8,11 @@ import os
|
||||
|
||||
import pytest
|
||||
|
||||
from mvt.common.cli_plugins import (
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
from mvt.common.indicators import Indicators
|
||||
|
||||
from .artifacts.generate_stix import generate_test_stix_file
|
||||
@@ -58,3 +63,33 @@ def indicators_factory(indicator_file):
|
||||
return ind
|
||||
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def restore_cli_commands(monkeypatch):
|
||||
"""Keep the external commands a test registers out of the next test.
|
||||
|
||||
Each CLI group is a module-level object shared by every test, so a test
|
||||
registering plugin or environment commands on one has to put it back. The
|
||||
groups are imported here rather than at the top of the file, so that
|
||||
collecting the tests does not import three CLIs for the sake of one
|
||||
fixture.
|
||||
"""
|
||||
from mvt.android.cli import cli as android_cli
|
||||
from mvt.cli import cli as neutral_cli
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
|
||||
groups = (neutral_cli, ios_cli, android_cli)
|
||||
for variable in (
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
):
|
||||
monkeypatch.delenv(variable, raising=False)
|
||||
originals = [dict(group.commands) for group in groups]
|
||||
yield
|
||||
for group, commands in zip(groups, originals):
|
||||
group.commands.clear()
|
||||
group.commands.update(commands)
|
||||
if hasattr(group, "_mvt_external_command_sources"):
|
||||
delattr(group, "_mvt_external_command_sources")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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/
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.cli import cli
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
from mvt.common.version import MVT_VERSION
|
||||
|
||||
# Keep the banner of the group callback from checking for updates online.
|
||||
OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"]
|
||||
|
||||
|
||||
class TestMvtCommand:
|
||||
def test_running_mvt_alone_shows_the_logo_and_the_commands(self):
|
||||
result = CliRunner().invoke(cli, OFFLINE)
|
||||
|
||||
assert result.exit_code == 0
|
||||
logo_at = result.output.index("Mobile Verification Toolkit")
|
||||
usage_at = result.output.index("Usage:")
|
||||
assert logo_at < usage_at
|
||||
assert "mvt-ios" in result.output and "mvt-android" in result.output
|
||||
|
||||
def test_help_reminds_where_the_analysis_runs(self):
|
||||
result = CliRunner().invoke(cli, ["--help"])
|
||||
|
||||
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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"Version: {MVT_VERSION}" in result.output
|
||||
|
||||
def test_download_iocs_updates_the_indicators(self, monkeypatch):
|
||||
updates = []
|
||||
monkeypatch.setattr(
|
||||
IndicatorsUpdates, "update", lambda self: updates.append(self)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, [*OFFLINE, "download-iocs"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert len(updates) == 1
|
||||
@@ -10,10 +10,20 @@ import click
|
||||
import pytest
|
||||
|
||||
import mvt.android
|
||||
import mvt.cli
|
||||
import mvt.ios
|
||||
from mvt.android.cli import cli as android_cli
|
||||
from mvt.android.cli import main as android_main
|
||||
from mvt.common.cli_plugins import ANDROID_CLI_PLUGIN_GROUP, IOS_CLI_PLUGIN_GROUP
|
||||
from mvt.cli import cli as mvt_cli
|
||||
from mvt.cli import main as mvt_main
|
||||
from mvt.common.cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
)
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
from mvt.ios.cli import main as ios_main
|
||||
|
||||
@@ -39,23 +49,32 @@ def cli():
|
||||
"""
|
||||
|
||||
PROGRAMS = {
|
||||
"mvt-ios": (mvt.ios, ios_cli, IOS_CLI_PLUGIN_GROUP),
|
||||
"mvt-android": (mvt.android, android_cli, ANDROID_CLI_PLUGIN_GROUP),
|
||||
"mvt": (mvt.cli, mvt_cli, NEUTRAL_CLI_PLUGIN_GROUP, MVT_CUSTOM_COMMANDS_ENV),
|
||||
"mvt-ios": (mvt.ios, ios_cli, IOS_CLI_PLUGIN_GROUP, MVT_IOS_CUSTOM_COMMANDS_ENV),
|
||||
"mvt-android": (
|
||||
mvt.android,
|
||||
android_cli,
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
),
|
||||
}
|
||||
|
||||
CASE_SUMMARY_COMMAND = """
|
||||
import click
|
||||
|
||||
@pytest.fixture
|
||||
def restore_cli_commands():
|
||||
"""Undo the plugin registration main() performs on the shared CLI groups."""
|
||||
originals = {
|
||||
program: dict(group.commands) for program, (_, group, _) in PROGRAMS.items()
|
||||
}
|
||||
yield
|
||||
for program, (_, group, _) in PROGRAMS.items():
|
||||
group.commands.clear()
|
||||
group.commands.update(originals[program])
|
||||
if hasattr(group, "_mvt_external_command_sources"):
|
||||
delattr(group, "_mvt_external_command_sources")
|
||||
|
||||
@click.command("case-summary")
|
||||
def cli():
|
||||
click.echo("case summary ran")
|
||||
"""
|
||||
|
||||
# The entry-point group of another program, for each program: no group may add
|
||||
# its commands to a CLI other than its own.
|
||||
OTHER_PROGRAMS_GROUP = {
|
||||
"mvt": IOS_CLI_PLUGIN_GROUP,
|
||||
"mvt-ios": NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
"mvt-android": NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
}
|
||||
|
||||
|
||||
def _install_fixture_entry_point(monkeypatch, entry_point_group, command):
|
||||
@@ -92,7 +111,7 @@ def _offline_argv(program, *arguments):
|
||||
def test_main_registers_installed_plugins_before_running_the_cli(
|
||||
program, monkeypatch, capsys, restore_cli_commands
|
||||
):
|
||||
package, group, entry_point_group = PROGRAMS[program]
|
||||
package, group, entry_point_group, _ = PROGRAMS[program]
|
||||
|
||||
@click.command()
|
||||
def fixture_command():
|
||||
@@ -113,7 +132,7 @@ def test_main_registers_installed_plugins_before_running_the_cli(
|
||||
def test_main_completes_plugin_command_names(
|
||||
program, monkeypatch, capsys, restore_cli_commands
|
||||
):
|
||||
package, _, entry_point_group = PROGRAMS[program]
|
||||
package, _, entry_point_group, _ = PROGRAMS[program]
|
||||
|
||||
@click.command()
|
||||
def fixture_command():
|
||||
@@ -136,17 +155,9 @@ def test_main_completes_plugin_command_names(
|
||||
def test_main_still_loads_commands_from_a_file(
|
||||
program, monkeypatch, capsys, tmp_path, restore_cli_commands
|
||||
):
|
||||
package, _, entry_point_group = PROGRAMS[program]
|
||||
package, _, entry_point_group, _ = PROGRAMS[program]
|
||||
command_path = tmp_path / "case_summary.py"
|
||||
command_path.write_text(
|
||||
"import click\n"
|
||||
"\n"
|
||||
"\n"
|
||||
'@click.command("case-summary")\n'
|
||||
"def cli():\n"
|
||||
' click.echo("case summary ran")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
command_path.write_text(CASE_SUMMARY_COMMAND, encoding="utf-8")
|
||||
_install_fixture_entry_point(
|
||||
monkeypatch, entry_point_group, click.Command("unused")
|
||||
)
|
||||
@@ -163,12 +174,66 @@ def test_main_still_loads_commands_from_a_file(
|
||||
assert "case summary ran" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("program", sorted(PROGRAMS))
|
||||
def test_main_loads_commands_from_the_environment_variable(
|
||||
program, monkeypatch, capsys, tmp_path, restore_cli_commands
|
||||
):
|
||||
# Each CLI reads its own variable, so a main() reading another CLI's would
|
||||
# go unnoticed without this.
|
||||
package, _, _, environment_variable = PROGRAMS[program]
|
||||
command_path = tmp_path / "case_summary.py"
|
||||
command_path.write_text(CASE_SUMMARY_COMMAND, encoding="utf-8")
|
||||
monkeypatch.setenv(environment_variable, str(command_path))
|
||||
monkeypatch.setattr(sys, "argv", _offline_argv(program, "case-summary"))
|
||||
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
package.main()
|
||||
|
||||
assert exit_info.value.code == 0
|
||||
assert "case summary ran" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("program", sorted(PROGRAMS))
|
||||
def test_main_ignores_the_entry_point_groups_of_the_other_programs(
|
||||
program, monkeypatch, capsys, restore_cli_commands
|
||||
):
|
||||
package, group, _, _ = PROGRAMS[program]
|
||||
_install_fixture_entry_point(
|
||||
monkeypatch,
|
||||
OTHER_PROGRAMS_GROUP[program],
|
||||
click.Command(FIXTURE_COMMAND_NAME),
|
||||
)
|
||||
monkeypatch.setattr(sys, "argv", _offline_argv(program, "--help"))
|
||||
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
package.main()
|
||||
|
||||
assert exit_info.value.code == 0
|
||||
assert FIXTURE_COMMAND_NAME not in group.commands
|
||||
assert FIXTURE_COMMAND_NAME not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_the_console_script_targets_are_importable():
|
||||
# [project.scripts] points at these, so they must stay on the packages.
|
||||
# [project.scripts] points at these, so they must stay where they are.
|
||||
assert mvt.cli.main is mvt_main
|
||||
assert mvt.ios.main is ios_main
|
||||
assert mvt.android.main is android_main
|
||||
|
||||
|
||||
def test_importing_mvt_does_not_import_a_cli(tmp_path):
|
||||
# The mvt package deliberately re-exports nothing of mvt.cli, so that
|
||||
# importing MVT stays cheap and free of side effects.
|
||||
result = run_isolated_python(
|
||||
"import sys\n"
|
||||
"import mvt\n"
|
||||
"print('imported a cli' if 'mvt.cli' in sys.modules else 'imported mvt')\n",
|
||||
home=tmp_path / "home",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "imported mvt"
|
||||
|
||||
|
||||
def test_importing_mvt_does_not_run_installed_plugins(tmp_path):
|
||||
site_path = write_cli_plugin_distribution(
|
||||
tmp_path / "site", IOS_CLI_PLUGIN_GROUP, MARKER_PLUGIN_TEMPLATE
|
||||
|
||||
Reference in New Issue
Block a user