mirror of
https://github.com/mvt-project/mvt.git
synced 2026-08-22 10:57:20 +02:00
* Load installed module packages via entry points Python packages can already register custom CLI commands which load automatically, but custom modules still require --load-module or the MVT_CUSTOM_MODULES environment variable on every invocation. Add an mvt.modules entry-point group so installed packages can register forensic modules which load automatically into every module-running check-* command. An entry point resolves to an iterable of MVTModule subclasses, or a callable returning one. Broken entry points are skipped with a warning so a faulty package cannot break MVT. * Record the source of loaded modules for auditability Now that installed module packages load automatically, record where every module came from: - --list-modules groups the available modules by source, one line per source with the modules comma-separated: MVT itself with its version, each installed package with its version and VCS commit when recorded (PEP 610 direct_url.json), and each --load-module/MVT_CUSTOM_MODULES file with its SHA-256 hash. - Commands log one line per module source with its version or hash and the modules loaded from it, so command.log records exactly which modules ran and where they came from. - Make init_logging() idempotent: a loaded module package importing an MVT CLI module would previously add a second console handler and duplicate every console log line. * Route loaded module logging under the mvt.ext namespace Modules loaded from installed packages or file paths live outside the mvt logger hierarchy, so their log records never reach MVT's console and file handlers and instead fall through to logging.lastResort: alerts print as bare unformatted lines and INFO messages are dropped entirely. Add get_module_logger() and use it everywhere module loggers are created. Built-in mvt.* modules keep their existing logger names, and everything external is parented under a dedicated mvt.ext namespace so records reach the handlers and external names can never collide with MVT's internal logger tree. File-path modules are named after their file (mvt.ext.<stem>) instead of the mangled internal import name. Document a naming convention for community module packages: distribute as mvt-plugin-<name> with import package mvt_plugin_<name>, including the publishing organization in the name. The prefix is advisory (loading is by entry point, and it is no mark of authenticity), but conforming packages get a cleaner logger namespace: the mvt_plugin_ prefix is stripped, so mvt_plugin_amnesty_custom logs as mvt.ext.amnesty_custom.
346 lines
9.8 KiB
Python
346 lines
9.8 KiB
Python
import hashlib
|
|
import importlib.metadata
|
|
import json
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from mvt.android.cli import check_bugreport
|
|
from mvt.android.cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
|
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup
|
|
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
|
|
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
|
from mvt.common import module_loader
|
|
from mvt.common.module import MVTModule
|
|
from mvt.common.version import MVT_VERSION
|
|
from mvt.ios.cli import check_backup, check_fs
|
|
|
|
|
|
CUSTOM_MODULE = """
|
|
from mvt.common.module import MVTModule
|
|
|
|
|
|
class {name}(MVTModule):
|
|
supported_commands = {supported_commands!r}
|
|
slug = "{slug}"
|
|
|
|
def run(self):
|
|
self.results = [{{"message": "custom module ran"}}]
|
|
|
|
def check_indicators(self):
|
|
pass
|
|
|
|
def serialize(self, result):
|
|
return None
|
|
"""
|
|
|
|
|
|
def _write_custom_module(path, name, supported_commands, slug=None):
|
|
path.write_text(
|
|
CUSTOM_MODULE.format(
|
|
name=name,
|
|
supported_commands=supported_commands,
|
|
slug=slug or name.lower(),
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
def test_load_module_appears_only_for_supported_cli_command(tmp_path):
|
|
module_path = _write_custom_module(
|
|
tmp_path / "custom.py",
|
|
"IOSBackupOnlyModule",
|
|
(("ios", "check-backup"),),
|
|
)
|
|
|
|
backup_result = CliRunner().invoke(
|
|
check_backup,
|
|
["--list-modules", "--load-module", str(module_path), str(tmp_path)],
|
|
)
|
|
fs_result = CliRunner().invoke(
|
|
check_fs,
|
|
["--list-modules", "--load-module", str(module_path), str(tmp_path)],
|
|
)
|
|
|
|
assert backup_result.exit_code == 0
|
|
assert "IOSBackupOnlyModule" in backup_result.output
|
|
assert fs_result.exit_code == 0
|
|
assert "IOSBackupOnlyModule" not in fs_result.output
|
|
|
|
|
|
def test_module_option_runs_supported_custom_module(tmp_path):
|
|
(tmp_path / "Manifest.db").touch()
|
|
(tmp_path / "Info.plist").touch()
|
|
module_path = _write_custom_module(
|
|
tmp_path / "custom.py",
|
|
"CustomRunModule",
|
|
(("ios", "check-backup"),),
|
|
slug="custom_run_module",
|
|
)
|
|
output_path = tmp_path / "out"
|
|
|
|
result = CliRunner().invoke(
|
|
check_backup,
|
|
[
|
|
"--module",
|
|
"CustomRunModule",
|
|
"--load-module",
|
|
str(module_path),
|
|
"--output",
|
|
str(output_path),
|
|
str(tmp_path),
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert (output_path / "custom_run_module.json").exists()
|
|
|
|
|
|
def test_custom_modules_load_from_environment_without_cli_flag(tmp_path, monkeypatch):
|
|
custom_modules_path = tmp_path / "custom_modules"
|
|
custom_modules_path.mkdir()
|
|
_write_custom_module(
|
|
custom_modules_path / "env_module.py",
|
|
"EnvBugreportModule",
|
|
(("android", "check-bugreport"),),
|
|
)
|
|
monkeypatch.setenv("MVT_CUSTOM_MODULES", str(custom_modules_path))
|
|
|
|
result = CliRunner().invoke(check_bugreport, ["--list-modules", str(tmp_path)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "EnvBugreportModule" in result.output
|
|
|
|
|
|
class InstalledPackageModule(MVTModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
|
|
|
|
def get_installed_package_modules():
|
|
return [InstalledPackageModule]
|
|
|
|
|
|
def _fake_entry_points(monkeypatch, value, name="test-modules"):
|
|
entry_point = importlib.metadata.EntryPoint(
|
|
name=name, value=value, group=module_loader.MODULES_ENTRY_POINT_GROUP
|
|
)
|
|
|
|
def fake_entry_points(*, group):
|
|
assert group == module_loader.MODULES_ENTRY_POINT_GROUP
|
|
return [entry_point]
|
|
|
|
monkeypatch.setattr(
|
|
module_loader.importlib.metadata, "entry_points", fake_entry_points
|
|
)
|
|
|
|
|
|
def test_installed_module_package_loads_from_entry_point(monkeypatch):
|
|
_fake_entry_points(monkeypatch, f"{__name__}:get_installed_package_modules")
|
|
|
|
modules = module_loader.load_custom_modules()
|
|
|
|
assert modules == [InstalledPackageModule]
|
|
|
|
|
|
def test_broken_module_entry_point_is_skipped(monkeypatch, caplog):
|
|
_fake_entry_points(monkeypatch, "nonexistent_module_xyz:get_modules")
|
|
|
|
with caplog.at_level("WARNING"):
|
|
modules = module_loader.load_custom_modules()
|
|
|
|
assert modules == []
|
|
assert "Unable to load modules from entry point" in caplog.text
|
|
|
|
|
|
def test_entry_point_module_deduplicated_against_paths(monkeypatch, tmp_path):
|
|
_fake_entry_points(monkeypatch, f"{__name__}:get_installed_package_modules")
|
|
module_path = _write_custom_module(
|
|
tmp_path / "custom.py",
|
|
"PathLoadedModule",
|
|
(("ios", "check-backup"),),
|
|
)
|
|
|
|
modules = module_loader.load_custom_modules([str(module_path)])
|
|
|
|
assert [module.__name__ for module in modules] == [
|
|
"InstalledPackageModule",
|
|
"PathLoadedModule",
|
|
]
|
|
|
|
|
|
def test_list_modules_shows_module_sources(tmp_path, caplog):
|
|
module_path = _write_custom_module(
|
|
tmp_path / "custom.py",
|
|
"SourcedBackupModule",
|
|
(("ios", "check-backup"),),
|
|
)
|
|
file_sha256 = hashlib.sha256(module_path.read_bytes()).hexdigest()
|
|
custom_modules = module_loader.load_custom_modules([str(module_path)])
|
|
|
|
from mvt.ios.cmd_check_backup import CmdIOSCheckBackup
|
|
|
|
cmd = CmdIOSCheckBackup(target_path=str(tmp_path), custom_modules=custom_modules)
|
|
cmd.list_modules()
|
|
|
|
assert f" - Modules from 'mvt@{MVT_VERSION}':" in caplog.text
|
|
assert (
|
|
f" - Modules from '{module_path}' (sha256: {file_sha256}): SourcedBackupModule"
|
|
in caplog.text
|
|
)
|
|
|
|
|
|
def test_builtin_module_origin():
|
|
from mvt.ios.modules.backup import BACKUP_MODULES
|
|
|
|
origin = module_loader.get_module_origin(BACKUP_MODULES[0])
|
|
|
|
assert origin.kind == "builtin"
|
|
assert origin.name == "mvt"
|
|
assert origin.version == MVT_VERSION
|
|
|
|
|
|
def test_installed_module_origin(monkeypatch):
|
|
_fake_entry_points(monkeypatch, f"{__name__}:get_installed_package_modules")
|
|
|
|
modules = module_loader.load_custom_modules()
|
|
|
|
origin = module_loader.get_module_origin(modules[0])
|
|
assert origin.kind == "package"
|
|
assert origin.name == "test-modules"
|
|
|
|
|
|
def test_distribution_commit_read_from_direct_url():
|
|
class FakeDistribution:
|
|
def read_text(self, filename):
|
|
assert filename == "direct_url.json"
|
|
return json.dumps(
|
|
{
|
|
"url": "https://github.com/example/example-modules",
|
|
"vcs_info": {"commit_id": "abc1234", "vcs": "git"},
|
|
}
|
|
)
|
|
|
|
assert module_loader._distribution_commit(FakeDistribution()) == "abc1234"
|
|
|
|
|
|
def test_command_log_records_loaded_modules(tmp_path):
|
|
(tmp_path / "Manifest.db").touch()
|
|
(tmp_path / "Info.plist").touch()
|
|
module_path = _write_custom_module(
|
|
tmp_path / "custom.py",
|
|
"AuditedRunModule",
|
|
(("ios", "check-backup"),),
|
|
slug="audited_run_module",
|
|
)
|
|
file_sha256 = hashlib.sha256(module_path.read_bytes()).hexdigest()
|
|
output_path = tmp_path / "out"
|
|
|
|
result = CliRunner().invoke(
|
|
check_backup,
|
|
[
|
|
"--module",
|
|
"AuditedRunModule",
|
|
"--load-module",
|
|
str(module_path),
|
|
"--output",
|
|
str(output_path),
|
|
str(tmp_path),
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
command_log = (output_path / "command.log").read_text(encoding="utf-8")
|
|
assert (
|
|
f"Loaded 1 check-backup modules from '{module_path}' "
|
|
f"(sha256: {file_sha256}): AuditedRunModule" in command_log
|
|
)
|
|
|
|
|
|
class NestedBugreportModule(MVTModule):
|
|
supported_commands = (("android", "check-bugreport"),)
|
|
|
|
|
|
class NestedBackupModule(MVTModule):
|
|
supported_commands = (("android", "check-backup"),)
|
|
|
|
|
|
class NestedIntrusionLogsModule(MVTModule):
|
|
supported_commands = (("android", "check-intrusion-logs"),)
|
|
|
|
|
|
class NestedAndroidQFModule(MVTModule):
|
|
supported_commands = (("android", "check-androidqf"),)
|
|
|
|
|
|
class DummyZip:
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def test_androidqf_propagates_custom_modules_to_nested_commands(tmp_path, monkeypatch):
|
|
records = {}
|
|
custom_modules = [
|
|
NestedBugreportModule,
|
|
NestedBackupModule,
|
|
NestedIntrusionLogsModule,
|
|
NestedAndroidQFModule,
|
|
]
|
|
cmd = CmdAndroidCheckAndroidQF(
|
|
target_path=str(tmp_path),
|
|
custom_modules=custom_modules,
|
|
)
|
|
|
|
def record_available(name):
|
|
def _record(command):
|
|
records[name] = [
|
|
module.__name__
|
|
for module in command._available_modules()
|
|
if module.__name__.startswith("Nested")
|
|
]
|
|
|
|
return _record
|
|
|
|
monkeypatch.setattr(cmd, "load_bugreport", lambda: DummyZip())
|
|
monkeypatch.setattr(
|
|
CmdAndroidCheckBugreport,
|
|
"from_zip",
|
|
lambda self, bugreport: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
CmdAndroidCheckBugreport,
|
|
"run",
|
|
record_available("bugreport"),
|
|
)
|
|
|
|
monkeypatch.setattr(cmd, "load_backup", lambda: b"")
|
|
monkeypatch.setattr(CmdAndroidCheckBackup, "from_ab", lambda self, backup: None)
|
|
monkeypatch.setattr(
|
|
CmdAndroidCheckBackup,
|
|
"run",
|
|
record_available("backup"),
|
|
)
|
|
|
|
intrusion_logs_path = tmp_path / "intrusion_logs"
|
|
intrusion_logs_path.mkdir()
|
|
setattr(cmd, "_CmdAndroidCheckAndroidQF__format", "dir")
|
|
setattr(
|
|
cmd,
|
|
"_CmdAndroidCheckAndroidQF__files",
|
|
["androidqf/intrusion_logs/security.txt"],
|
|
)
|
|
monkeypatch.setattr(cmd, "_read_device_timezone", lambda: None)
|
|
monkeypatch.setattr(
|
|
CmdAndroidCheckIntrusionLogs,
|
|
"run",
|
|
record_available("intrusion_logs"),
|
|
)
|
|
|
|
assert cmd.run_bugreport_cmd()
|
|
assert cmd.run_backup_cmd()
|
|
assert cmd.run_intrusion_logs_cmd()
|
|
assert records == {
|
|
"bugreport": ["NestedBugreportModule"],
|
|
"backup": ["NestedBackupModule"],
|
|
"intrusion_logs": ["NestedIntrusionLogsModule"],
|
|
}
|