From 2eb40b85cfd3d3788bb4600c49906e136a6c7db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Sat, 5 Sep 2026 23:45:14 +0200 Subject: [PATCH] Add a SysdiagnoseInfo module to check-sysdiagnose (#917) * Add a SysdiagnoseInfo module to check-sysdiagnose check-sysdiagnose had no module of its own: it prepared the archive for plugin modules and refused to run without one. SysdiagnoseInfo is the first built-in module. It writes sysdiagnose_info.json with details about the device and the archive: product type and model, iOS version and build, serial number, IMEI, MEID and UDID from remotectl_dumpstate.txt and the mobile activation request, the Apple account name and email from the App Store daemon database, and the archive's original file name and creation time from sysdiagnose.log. The build is checked against the known iOS versions the way BackupInfo does. The App Store database is copied out of the archive together with its -wal and -shm sidecars before it is opened, so rows still in the write-ahead log are read. With a built-in module the command's list is never empty, so the "no custom modules" error and its test go. The module joins IOS_CHECK_IOCS_MODULES like every other module that writes a results file. * Note that newer sysdiagnoses lack the App Store daemon database * Keep refusing check-sysdiagnose runs without a custom module * Warn instead of refusing when no forensic sysdiagnose module is loaded --- docs/ios/records.md | 8 + docs/ios/sysdiagnose.md | 11 +- src/mvt/ios/cli.py | 13 +- src/mvt/ios/cmd_check_sysdiagnose.py | 3 + src/mvt/ios/command_modules.py | 3 +- src/mvt/ios/modules/sysdiagnose/__init__.py | 5 + .../modules/sysdiagnose/sysdiagnose_info.py | 228 ++++++++++++++++++ tests/common/test_command_modules.py | 5 +- tests/ios_sysdiagnose/__init__.py | 4 + .../ios_sysdiagnose/test_sysdiagnose_info.py | 161 +++++++++++++ tests/test_check_ios_sysdiagnose.py | 15 +- tests/test_cmd_check_sysdiagnose.py | 17 +- 12 files changed, 451 insertions(+), 22 deletions(-) create mode 100644 src/mvt/ios/modules/sysdiagnose/sysdiagnose_info.py create mode 100644 tests/ios_sysdiagnose/__init__.py create mode 100644 tests/ios_sysdiagnose/test_sysdiagnose_info.py diff --git a/docs/ios/records.md b/docs/ios/records.md index b34183ad..856d0db9 100644 --- a/docs/ios/records.md +++ b/docs/ios/records.md @@ -435,3 +435,11 @@ This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extr This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup. + +--- + +## Records extracted by `check-sysdiagnose` + +### `sysdiagnose_info.json` + +This JSON file is created by mvt-ios' `SysdiagnoseInfo` module. The module extracts details about the device and the sysdiagnose itself: the product type and model, iOS version and build, serial number, IMEI, MEID and UDID from the remotectl dump state and the mobile activation request, the Apple account name and email from the App Store daemon database (no longer part of a sysdiagnose on newer iOS versions, still read from older archives), and the original file name and creation time of the archive from *sysdiagnose.log*. diff --git a/docs/ios/sysdiagnose.md b/docs/ios/sysdiagnose.md index d0d84fb3..0107ce6b 100644 --- a/docs/ios/sysdiagnose.md +++ b/docs/ios/sysdiagnose.md @@ -1,10 +1,13 @@ # Check an iOS Sysdiagnose -`mvt-ios check-sysdiagnose` prepares an iOS sysdiagnose archive for analysis by -custom MVT modules. MVT does not include built-in sysdiagnose modules. The -command runs the modules of the installed +`mvt-ios check-sysdiagnose` analyzes an iOS sysdiagnose archive. MVT's own +`SysdiagnoseInfo` module extracts details about the device and the archive +(see [`sysdiagnose_info.json`](records.md#sysdiagnose_infojson)); the checks +come from the modules of the installed [plugin packages](../development/index.md#installed-module-packages) which -declare support for it. Install at least one such package first. +declare support for the command. Without any such module the command still +records the device details, and warns that no forensic sysdiagnose modules +have been loaded so that the run cannot pass for a clean analysis. The command accepts either an extracted sysdiagnose directory or the original gzip-compressed tar archive. diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index 56c4a11d..4560010a 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -449,11 +449,14 @@ def check_sysdiagnose( custom_modules=custom_modules, ) - if not cmd._available_modules(): - raise click.ClickException( - "No custom modules support mvt-ios check-sysdiagnose. " - "Load a module that declares supported_commands = " - "((\"ios\", \"check-sysdiagnose\"),)." + # MVT's own module only records the device details; the checks come from + # custom modules, so a run without any must not look like a clean analysis. + if all(module in cmd.modules for module in cmd._available_modules()): + log.warning( + "No forensic sysdiagnose modules have been loaded: MVT's own " + "SysdiagnoseInfo module only records the device details. Install a " + "module package or load a module that declares supported_commands = " + '(("ios", "check-sysdiagnose"),) to check the sysdiagnose.' ) if list_modules: diff --git a/src/mvt/ios/cmd_check_sysdiagnose.py b/src/mvt/ios/cmd_check_sysdiagnose.py index 05d5b6b7..3450a03f 100644 --- a/src/mvt/ios/cmd_check_sysdiagnose.py +++ b/src/mvt/ios/cmd_check_sysdiagnose.py @@ -16,6 +16,8 @@ from mvt.common.command import Command from mvt.common.indicators import Indicators from mvt.common.module import MVTModule +from .modules.sysdiagnose import SYSDIAGNOSE_MODULES + log = logging.getLogger(__name__) @@ -52,6 +54,7 @@ class CmdIOSCheckSysdiagnose(Command): ) self.platform = "ios" self.name = "check-sysdiagnose" + self.modules = SYSDIAGNOSE_MODULES self.sysdiagnose_format: Optional[str] = None self.sysdiagnose_archive: Optional[tarfile.TarFile] = None self.sysdiagnose_files: list[str] = [] diff --git a/src/mvt/ios/command_modules.py b/src/mvt/ios/command_modules.py index 26fa1d61..ab12d607 100644 --- a/src/mvt/ios/command_modules.py +++ b/src/mvt/ios/command_modules.py @@ -16,7 +16,8 @@ from mvt.common.module import MVTModule from .modules.backup import BACKUP_MODULES from .modules.fs import FS_MODULES from .modules.mixed import MIXED_MODULES +from .modules.sysdiagnose import SYSDIAGNOSE_MODULES IOS_CHECK_IOCS_MODULES: list[type[MVTModule]] = ( - BACKUP_MODULES + FS_MODULES + MIXED_MODULES + BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES ) diff --git a/src/mvt/ios/modules/sysdiagnose/__init__.py b/src/mvt/ios/modules/sysdiagnose/__init__.py index 3963ca54..4f586ecc 100644 --- a/src/mvt/ios/modules/sysdiagnose/__init__.py +++ b/src/mvt/ios/modules/sysdiagnose/__init__.py @@ -3,4 +3,9 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +from mvt.common.module import MVTModule + from .base import SysdiagnoseExtraction +from .sysdiagnose_info import SysdiagnoseInfo + +SYSDIAGNOSE_MODULES: list[type[MVTModule]] = [SysdiagnoseInfo] diff --git a/src/mvt/ios/modules/sysdiagnose/sysdiagnose_info.py b/src/mvt/ios/modules/sysdiagnose/sysdiagnose_info.py new file mode 100644 index 00000000..c37208c2 --- /dev/null +++ b/src/mvt/ios/modules/sysdiagnose/sysdiagnose_info.py @@ -0,0 +1,228 @@ +# 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 json +import logging +import os +import plistlib +import re +import sqlite3 +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Optional + +from mvt.common.module_types import ModuleResults +from mvt.common.utils import convert_datetime_to_iso +from mvt.ios.versions import ( + find_version_by_build, + get_device_desc_from_id, + is_ios_version_outdated, +) + +from .base import SysdiagnoseExtraction + +# The fields dumpsys prints in the remotectl dump state and the mobile +# activation request which are worth a log line of their own. +LOGGED_FIELDS = ( + "ProductName", + "ProductType", + "SerialNumber", + "OSVersion", + "RegionCode", + "IMEI", + "BuildVersion", +) + + +class SysdiagnoseInfo(SysdiagnoseExtraction): + """Extract details about the device and the sysdiagnose itself. + + The fields come from four files of the archive: the remotectl dump state + (product type, OS version, serial number, region and the rest of its + Properties block), the mobile activation request (UDID, IMEI, MEID and the + OS build), the App Store daemon database (the Apple account name and email) + and sysdiagnose.log (the archive's original file name and creation time). + + Newer iOS versions no longer include the App Store daemon database in a + sysdiagnose; it is still read for the analysis of older archives. + """ + + def __init__( + self, + file_path: Optional[str] = None, + target_path: Optional[str] = None, + results_path: Optional[str] = None, + module_options: Optional[dict] = None, + log: logging.Logger = logging.getLogger(__name__), + results: Optional[ModuleResults] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + + self.results: dict = results if results is not None else {} + + def _copy_sqlite_db(self, file_path: str, directory: str) -> str: + """Copy a database and its WAL sidecars out of the archive. + + A database dumped mid-transaction keeps its latest rows in the -wal + file next to it, which SQLite only reads when both sit in the same + directory under the same name. + """ + available_files = self.tar_files if self.tar else self.files + for suffix in ("", "-wal", "-shm"): + if suffix and f"{file_path}{suffix}" not in available_files: + continue + copy_path = os.path.join(directory, f"{Path(file_path).name}{suffix}") + with open(copy_path, "wb") as handle: + handle.write(self._get_file_content(f"{file_path}{suffix}")) + + return os.path.join(directory, Path(file_path).name) + + def _process_appstored(self, file_path: str) -> None: + self.log.info("Found App Store daemon database at: %s", file_path) + with tempfile.TemporaryDirectory(prefix="mvt_sqlite_") as directory: + db_path = Path(self._copy_sqlite_db(file_path, directory)).resolve() + conn = sqlite3.connect(f"{db_path.as_uri()}?mode=ro", uri=True) + try: + self._read_appstored(conn) + finally: + conn.close() + + def _read_appstored(self, conn: sqlite3.Connection) -> None: + cur = conn.cursor() + # The account name sits in an opaque structure of every asset row. + try: + rows = cur.execute("SELECT sinfs_data FROM asset;").fetchall() + except sqlite3.DatabaseError as exc: + self.log.debug("Unable to read the asset table: %s", exc) + rows = [] + + for (sinfs_data,) in rows: + try: + sinf = plistlib.loads(sinfs_data)[0]["sinf"] + except (plistlib.InvalidFileException, IndexError, KeyError, TypeError): + continue + match = re.search(rb"name(.*?)\x00", sinf) + if match: + self.results["Account Name"] = match.group(1).decode( + "utf-8", errors="replace" + ) + break + + try: + row = cur.execute( + "SELECT store_account_name FROM job_software " + "WHERE store_account_name IS NOT NULL LIMIT 1;" + ).fetchone() + except sqlite3.DatabaseError as exc: + self.log.debug("Unable to read the job_software table: %s", exc) + return + + if row: + self.results["Email Address"] = row[0] + + def _process_activation_log(self, file_path: str) -> None: + self.log.info("Found mobile activation request at: %s", file_path) + content = self._get_file_content(file_path) + match = re.search(rb"BODY:\s+({.+?})\s", content, re.MULTILINE) + if not match: + return + + try: + body = json.loads(match.group(1)) + except json.JSONDecodeError as exc: + self.log.warning("Unable to parse the activation request body: %s", exc) + return + + self.results.update( + { + "SerialNumber": body.get("serial-number"), + "ProductType": body.get("productType"), + "ProductName": body.get("productName"), + "IMEI": body.get("imei"), + "ProductVersion": body.get("os-version"), + "UniqueIdentifier": body.get("udid"), + "MEID": body.get("meid"), + "BuildVersion": body.get("os-build"), + } + ) + + def _process_dumpstate(self, file_path: str) -> None: + self.log.info("Found remotectl dump state at: %s", file_path) + content = self._get_file_content(file_path).decode("utf-8", errors="replace") + in_properties = False + for line in content.splitlines(): + if not in_properties: + in_properties = line == "\tProperties: {" + continue + + if line == "\t}": + break + + key, separator, value = line.partition("=>") + if separator: + self.results[key.strip()] = value.strip() + + def _process_sysdiagnose_log(self, file_path: str) -> None: + self.log.info("Found sysdiagnose.log at: %s", file_path) + content = self._get_file_content(file_path).decode("utf-8", errors="replace") + match = re.search(r"sysdiagnose_\S+?\.tar\.gz", content) + if not match: + self.log.info("Could not find the original output path in sysdiagnose.log") + return + + file_name = os.path.basename(match.group(0)) + try: + created = datetime.strptime( + "_".join(file_name.split("_")[1:3]), "%Y.%m.%d_%H-%M-%S%z" + ) + except ValueError: + self.log.warning("Unexpected sysdiagnose file name: %s", file_name) + return + + self.results["OriginalFilename"] = file_name + self.results["CreatedTimestamp"] = convert_datetime_to_iso(created) + + def run(self) -> None: + for file_path in self._get_files_by_pattern( + "*/logs/appinstallation/appstored.sqlitedb" + ): + self._process_appstored(file_path) + + for file_path in self._get_files_by_pattern( + "*/logs/MobileActivation/collection_oob_request.txt" + ): + self._process_activation_log(file_path) + + for file_path in self._get_files_by_pattern("*/remotectl_dumpstate.txt"): + self._process_dumpstate(file_path) + + for file_path in self._get_files_by_pattern("*/sysdiagnose.log"): + self._process_sysdiagnose_log(file_path) + + # The activation request names the product "iPhone OS"; the model + # description is what an analyst wants to read. + product_name = get_device_desc_from_id(self.results.get("ProductType", "")) + if product_name: + self.results["ProductName"] = product_name + + for field in LOGGED_FIELDS: + if field not in self.results: + continue + value = self.results[field] + if field == "BuildVersion" and value: + self.log.info("%s: %s - %s", field, value, find_version_by_build(value)) + else: + self.log.info("%s: %s", field, value) + + if self.results.get("BuildVersion"): + is_ios_version_outdated(self.results["BuildVersion"], self.log) diff --git a/tests/common/test_command_modules.py b/tests/common/test_command_modules.py index 8f79ce9d..a79cda2c 100644 --- a/tests/common/test_command_modules.py +++ b/tests/common/test_command_modules.py @@ -12,13 +12,16 @@ from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES from mvt.ios.modules.backup import BACKUP_MODULES as IOS_BACKUP_MODULES from mvt.ios.modules.fs import FS_MODULES from mvt.ios.modules.mixed import MIXED_MODULES +from mvt.ios.modules.sysdiagnose import SYSDIAGNOSE_MODULES def test_the_check_iocs_lists_are_the_families_of_their_platform(): # The CLI reads these same lists, so nothing composing one elsewhere can # drift from what the command runs. This pins what the lists are composed # of. - assert IOS_CHECK_IOCS_MODULES == IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES + assert IOS_CHECK_IOCS_MODULES == ( + IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES + ) assert ANDROID_CHECK_IOCS_MODULES == ( ANDROID_BACKUP_MODULES + BUGREPORT_MODULES diff --git a/tests/ios_sysdiagnose/__init__.py b/tests/ios_sysdiagnose/__init__.py new file mode 100644 index 00000000..4e7aeb6d --- /dev/null +++ b/tests/ios_sysdiagnose/__init__.py @@ -0,0 +1,4 @@ +# 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/ diff --git a/tests/ios_sysdiagnose/test_sysdiagnose_info.py b/tests/ios_sysdiagnose/test_sysdiagnose_info.py new file mode 100644 index 00000000..d5590897 --- /dev/null +++ b/tests/ios_sysdiagnose/test_sysdiagnose_info.py @@ -0,0 +1,161 @@ +# 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 json +import plistlib +import sqlite3 +import tarfile + +from mvt.common.module import run_module +from mvt.ios.cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose +from mvt.ios.modules.sysdiagnose.sysdiagnose_info import SysdiagnoseInfo +from mvt.ios.versions import get_device_desc_from_id + +# The name sysdiagnose gives its archive: the time it ran, then the OS and build. +ARCHIVE_NAME = "sysdiagnose_2024.01.02_03-04-05+0200_iPhone-OS_iPhone_21C62" + +DUMPSTATE = ( + "Found device: ...\n" + "\tProperties: {\n" + "\t\tProductType => iPhone12,1\n" + "\t\tOSVersion => 17.2\n" + "\t\tSerialNumber => C0FFEE000000\n" + "\t\tRegionCode => LL\n" + "\t}\n" + "\tServices: {\n" + "\t\tcom.apple.example => ignored\n" + "\t}\n" +) + +ACTIVATION_BODY = { + "serial-number": "C0FFEE000000", + "productType": "iPhone12,1", + "productName": "iPhone OS", + "imei": "000000000000000", + "os-version": "17.2", + "os-build": "21C62", + "udid": "00000000-0000000000000000", + "meid": "00000000000000", +} + + +def make_sysdiagnose(tmp_path, activation_body=None): + folder = tmp_path / ARCHIVE_NAME + folder.mkdir() + (folder / "sysdiagnose.log").write_text( + f"Output available at '/private/var/tmp/{ARCHIVE_NAME}.tar.gz'\n", + encoding="utf-8", + ) + (folder / "remotectl_dumpstate.txt").write_text(DUMPSTATE, encoding="utf-8") + + activation = folder / "logs" / "MobileActivation" + activation.mkdir(parents=True) + body = json.dumps( + activation_body if activation_body is not None else ACTIVATION_BODY + ) + (activation / "collection_oob_request.txt").write_text( + f"HEADERS: {{}}\nBODY: {body}\nEND\n", encoding="utf-8" + ) + + appinstallation = folder / "logs" / "appinstallation" + appinstallation.mkdir(parents=True) + conn = sqlite3.connect(appinstallation / "appstored.sqlitedb") + conn.execute("CREATE TABLE asset (sinfs_data BLOB)") + conn.execute( + "INSERT INTO asset VALUES (?)", + (plistlib.dumps([{"sinf": b"\x00\x10nameExample Person\x00\x00rest"}]),), + ) + conn.execute("CREATE TABLE job_software (store_account_name TEXT)") + conn.execute("INSERT INTO job_software VALUES (NULL)") + conn.execute("INSERT INTO job_software VALUES ('person@example.com')") + conn.commit() + conn.close() + return folder + + +def run_command(target, results_path=None): + command = CmdIOSCheckSysdiagnose(target_path=str(target), results_path=results_path) + command.run() + (module,) = [m for m in command.executed if isinstance(m, SysdiagnoseInfo)] + return module + + +def test_device_details_from_a_sysdiagnose_folder(tmp_path): + results_path = tmp_path / "results" + results_path.mkdir() + module = run_command(make_sysdiagnose(tmp_path), str(results_path)) + + assert module.results["SerialNumber"] == "C0FFEE000000" + assert module.results["ProductType"] == "iPhone12,1" + assert module.results["ProductName"] == get_device_desc_from_id("iPhone12,1") + assert module.results["ProductName"] != "iPhone OS" + assert module.results["OSVersion"] == "17.2" + assert module.results["BuildVersion"] == "21C62" + assert module.results["UniqueIdentifier"] == "00000000-0000000000000000" + assert module.results["RegionCode"] == "LL" + assert "com.apple.example" not in module.results + assert module.results["Account Name"] == "Example Person" + assert module.results["Email Address"] == "person@example.com" + assert module.results["OriginalFilename"] == f"{ARCHIVE_NAME}.tar.gz" + assert module.results["CreatedTimestamp"] == "2024-01-02 01:04:05.000000" + assert (results_path / "sysdiagnose_info.json").exists() + + +def test_device_details_from_a_sysdiagnose_archive(tmp_path): + folder = make_sysdiagnose(tmp_path) + archive_path = tmp_path / f"{ARCHIVE_NAME}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(folder, arcname=ARCHIVE_NAME) + + module = run_command(archive_path) + + assert module.results["SerialNumber"] == "C0FFEE000000" + assert module.results["Account Name"] == "Example Person" + assert module.results["OriginalFilename"] == f"{ARCHIVE_NAME}.tar.gz" + + +def test_wal_sidecars_are_copied_beside_the_database(tmp_path): + folder = tmp_path / ARCHIVE_NAME + (folder / "logs").mkdir(parents=True) + (folder / "logs" / "db.sqlite").write_bytes(b"main") + (folder / "logs" / "db.sqlite-wal").write_bytes(b"wal") + module = SysdiagnoseInfo() + module.from_sysdiagnose_folder( + str(folder), + [f"{ARCHIVE_NAME}/logs/db.sqlite", f"{ARCHIVE_NAME}/logs/db.sqlite-wal"], + ) + copies = tmp_path / "copies" + copies.mkdir() + + db_path = module._copy_sqlite_db(f"{ARCHIVE_NAME}/logs/db.sqlite", str(copies)) + + assert db_path == str(copies / "db.sqlite") + assert (copies / "db.sqlite").read_bytes() == b"main" + assert (copies / "db.sqlite-wal").read_bytes() == b"wal" + assert not (copies / "db.sqlite-shm").exists() + + +def test_a_sysdiagnose_without_the_files_yields_nothing(tmp_path): + folder = tmp_path / ARCHIVE_NAME + folder.mkdir() + (folder / "other.txt").write_text("nothing here", encoding="utf-8") + + module = SysdiagnoseInfo() + module.from_sysdiagnose_folder(str(folder), [f"{ARCHIVE_NAME}/other.txt"]) + run_module(module) + + assert module.results == {} + + +def test_a_malformed_activation_request_is_skipped(tmp_path): + folder = make_sysdiagnose(tmp_path) + (folder / "logs" / "MobileActivation" / "collection_oob_request.txt").write_text( + "BODY: {not json}\n", encoding="utf-8" + ) + + module = run_command(folder) + + assert "IMEI" not in module.results + assert module.results["SerialNumber"] == "C0FFEE000000" diff --git a/tests/test_check_ios_sysdiagnose.py b/tests/test_check_ios_sysdiagnose.py index d73c0ffb..0cd4221a 100644 --- a/tests/test_check_ios_sysdiagnose.py +++ b/tests/test_check_ios_sysdiagnose.py @@ -1,3 +1,5 @@ +import logging + from click.testing import CliRunner from mvt.ios.cli import check_sysdiagnose @@ -50,8 +52,13 @@ def test_check_sysdiagnose_runs_explicitly_scoped_custom_module(tmp_path): assert (output_path / "custom_sysdiagnose_module.json").exists() -def test_check_sysdiagnose_requires_an_explicitly_scoped_module(tmp_path): - result = CliRunner().invoke(check_sysdiagnose, [str(_create_sysdiagnose_folder(tmp_path))]) +def test_check_sysdiagnose_warns_without_a_custom_module(tmp_path, caplog): + # The built-in SysdiagnoseInfo alone performs no check, so the run goes + # ahead but says so. + with caplog.at_level(logging.WARNING, logger="mvt"): + result = CliRunner().invoke( + check_sysdiagnose, [str(_create_sysdiagnose_folder(tmp_path))] + ) - assert result.exit_code != 0 - assert "No custom modules support mvt-ios check-sysdiagnose" in result.output + assert result.exit_code == 0 + assert "No forensic sysdiagnose modules have been loaded" in caplog.text diff --git a/tests/test_cmd_check_sysdiagnose.py b/tests/test_cmd_check_sysdiagnose.py index 67f126bd..020c8616 100644 --- a/tests/test_cmd_check_sysdiagnose.py +++ b/tests/test_cmd_check_sysdiagnose.py @@ -45,6 +45,11 @@ def _create_sysdiagnose_archive(tmp_path, folder): return archive_path +def _test_module(command): + (module,) = [m for m in command.executed if isinstance(m, SysdiagnoseTestModule)] + return module + + def _run_command(path): command = CmdIOSCheckSysdiagnose( target_path=str(path), custom_modules=[SysdiagnoseTestModule] @@ -56,10 +61,10 @@ def _run_command(path): def test_check_sysdiagnose_from_folder(tmp_path): command = _run_command(_create_sysdiagnose_folder(tmp_path)) - assert command.executed[0].results == [ + assert _test_module(command).results == [ {"content": "artifact", "timezone_offset": timedelta(hours=2).seconds} ] - assert command.executed[0].ips_files == [ + assert _test_module(command).ips_files == [ {"file_path": str(tmp_path / "sysdiagnose" / "report.ips"), "bug_type": 210} ] @@ -68,14 +73,12 @@ def test_check_sysdiagnose_from_archive_closes_archive(tmp_path): folder = _create_sysdiagnose_folder(tmp_path) command = _run_command(_create_sysdiagnose_archive(tmp_path, folder)) - assert command.executed[0].results == [ + assert _test_module(command).results == [ {"content": "artifact", "timezone_offset": timedelta(hours=2).seconds} ] - assert command.executed[0].ips_files == [ + assert _test_module(command).ips_files == [ { - "file_path": str( - Path(command.extracted_sysdiagnose_path) / "report.ips" - ), + "file_path": str(Path(command.extracted_sysdiagnose_path) / "report.ips"), "bug_type": 210, } ]