Compare commits

...
24 Commits
Author SHA1 Message Date
besendorf c95eeed825 Merge pull request #964 from forest-savage1234/docs/python-minimum-q093-20260929
Correct the Python minimum in installation documentation
2026-09-30 10:53:41 +02:00
Forest Savage 60d1b414fe Correct the Python minimum in installation documentation 2026-09-29 21:13:11 -08:00
besendorf 563fe44bf3 Merge pull request #959 from mvt-project/auto/add-new-ios-releases
[auto] Update iOS releases and versions
2026-09-29 20:44:00 +02:00
besendorf 85fca1c825 Merge branch 'main' into auto/add-new-ios-releases 2026-09-29 20:42:15 +02:00
besendorf 3704bf26c0 Merge pull request #960 from Yi-111-a/fix/manifest-flag-missing-backup-files
fix(manifest): flag backup files listed in the manifest but not stored
2026-09-29 20:41:50 +02:00
Janik Besendorf a80b0fff2e fix(manifest): validate backup inventory before marking files missing 2026-09-29 18:40:02 +02:00
Yi-111-a f98f4f6cd2 fix(manifest): flag backup files listed in the manifest but not stored
An incomplete iTunes backup still lists in its Manifest.db the files it
failed to acquire, so a module which found nothing for one of them looks
exactly like a module which found nothing on a device that never had the
artifact. The Manifest module now records a "missing" flag on those
records and reports the total, so a gap in the acquisition is visible in
manifest.json and in the command output.

The stored file IDs are collected by walking the backup folder once, which
keeps the check off the per-entry filesystem lookup path: on a 20k entry
manifest the module runs in the same time as before the change.
2026-09-29 23:59:15 +08:00
DonnchaC 3e99412c32 Add new iOS versions and build numbers 2026-09-28 18:17:44 +00:00
besendorf fd37c4d8d9 Merge pull request #957 from itzzdev09/fix/adb-state-bounds-main
Bound the ADB manager state by matching braces
2026-09-28 16:16:03 +02:00
Janik Besendorf c615d5e304 Merge main into PR #957 and preserve ADB state bounds 2026-09-28 16:12:15 +02:00
besendorf 9bdc25f57d Merge pull request #949 from NeuroSaki987/codex/fix-windows-dumpsys-crlf
fix: parse dumpsys ADB output with CRLF line endings
2026-09-28 16:06:05 +02:00
besendorf 8238d80035 Merge branch 'main' into codex/fix-windows-dumpsys-crlf 2026-09-28 16:04:49 +02:00
besendorf 22d97f0a88 Merge pull request #954 from SomeoneUnlicensed/fix/windows-tests
Make the test suite pass on Windows
2026-09-28 16:01:08 +02:00
besendorf d224db5a78 Merge branch 'main' into fix/windows-tests 2026-09-28 15:54:55 +02:00
Janik Besendorf 658c7aa327 Test Windows path normalization and preserve backup safety coverage 2026-09-28 15:47:15 +02:00
itzzdev09 9316ac7fb0 Bound the ADB manager state by matching braces 2026-09-28 02:07:03 +05:30
besendorf 6ddb2c9671 Merge branch 'main' into codex/fix-windows-dumpsys-crlf 2026-09-27 22:25:21 +02:00
besendorf aa3ae511ad Merge branch 'main' into codex/fix-windows-dumpsys-crlf 2026-09-27 19:24:54 +02:00
Neruo Saki 02aa23cb81 Merge branch 'main' into codex/fix-windows-dumpsys-crlf 2026-09-27 17:54:12 +08:00
SomeoneUnlicensed 99b3b5f014 Make the test suite pass on Windows
The Filesystem module stored the paths of an iOS dump with the separator
of the system checking it, so on Windows the process and file path
indicators, which split paths on "/", never matched. It now stores them
as POSIX paths.

The rest are test fixes:
- the completion install tests also redirect USERPROFILE, which
  Path.home() reads on Windows; they wrote to the real home folder;
- the plugin table helpers accept the light header Rich draws on
  consoles that cannot show the heavy one;
- the completion test quotes the command path, whose backslashes were
  dropped when COMP_WORDS was split;
- tests that need symbolic links or the sqlite3 binary are skipped when
  those are not available;
- two assertions no longer depend on the path separator or on the line
  ending text mode writes.
2026-09-24 21:29:45 +03:00
besendorf 9ca4254649 Fix formatting in test_artifact_dumpsys_adb.py 2026-09-24 09:38:18 -07:00
besendorf f1e52b297a test: cover mixed ADB state line endings
Regression for CRLF after the manager brace and LF after the outer brace.
2026-09-24 04:40:48 -07:00
besendorf 8c5278f540 Refactor JSON extraction logic in dumpsys_adb.py 2026-09-24 04:37:25 -07:00
StarRailHub ff5ebf73cc fix: parse dumpsys ADB output with CRLF line endings 2026-09-23 12:11:58 +08:00
17 changed files with 461 additions and 30 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Installation
Before proceeding, please note that MVT requires Python 3.6+ to run. While it should be available on most operating systems, please make sure of that before proceeding.
Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
## Dependencies on Linux
+4
View File
@@ -204,6 +204,10 @@ This JSON file is created by mvt-ios' `Manifest` module. The module extracts rec
If indicators are provided through the command-line, they are checked against the original relative path in case. In some cases, there might be records of files created containing a domain name in their name, for example in the case of browser cache folders. Any matches are stored in *manifest_detected.json*.
An incomplete backup still lists in its manifest the files it failed to acquire. Those records carry a `"missing": true` field, and the module reports how many of them it found. Use it to tell a module which returned nothing because the artifact was not acquired apart from one which found nothing on a device that never had it.
Files must be stored at their expected paths inside the backup folder. If the module cannot finish listing the backup files, it logs a warning and skips the missing-file check; the manifest metadata is still extracted.
---
### `os_analytics_ad_daily.json`
+45 -5
View File
@@ -29,11 +29,21 @@ class DumpsysADBArtifact(AndroidArtifact):
stack = [res]
cur_indent = 0
in_multiline = False
for line in dump_data.strip(b"\n").split(b"\n"):
for line in dump_data.strip(b"\r\n").split(b"\n"):
line = line.removesuffix(b"\r")
# Track the level of indentation
indent = len(line) - len(line.lstrip())
if indent < cur_indent:
# If the current line is less indented than the previous one, back out
if len(stack) <= 1:
# Dedenting below the outermost level means this is not the
# well-formed block the parser expects. Stop here rather than
# raise IndexError out of the module on the next line.
self.log.error(
"Unexpected indentation in ADB manager state, "
"stopping the parse of this section"
)
break
stack.pop()
cur_indent = indent
else:
@@ -63,6 +73,12 @@ class DumpsysADBArtifact(AndroidArtifact):
current_dict = stack[-1]
if key == "}":
if len(stack) <= 1:
self.log.error(
"Unbalanced closing brace in ADB manager state, "
"stopping the parse of this section"
)
break
stack.pop()
continue
@@ -161,6 +177,26 @@ class DumpsysADBArtifact(AndroidArtifact):
f"'{user_key['fingerprint']}'"
)
@staticmethod
def _find_state_end(content: bytes, open_brace: int) -> int:
"""Find the unindented line closing the ADB manager state, or -1.
Braces inside key comments or embedded keystore data are values, while
nested structural closing braces are indented.
"""
line_start = open_brace
while line_start < len(content):
line_end = content.find(b"\n", line_start)
if line_end == -1:
line_end = len(content)
line = content[line_start:line_end].removesuffix(b"\r")
if line_start > open_brace and line == b"}":
return line_start
if line.startswith((b"---------", b"DUMP OF SERVICE ")):
break
line_start = line_end + 1
return -1
def parse(self, content: bytes) -> None:
"""
Parse the Dumpsys ADB section
@@ -180,12 +216,16 @@ class DumpsysADBArtifact(AndroidArtifact):
self.log.error("Unable to find ADB manager state in dumpsys output")
return
end_of_json = content.rfind(b"}\n")
if end_of_json == -1 or end_of_json <= start_of_json:
self.log.error("Unable to find complete ADB manager state in dumpsys output")
end_of_json = self._find_state_end(content, start_of_json + 1)
if end_of_json == -1:
self.log.error(
"Unable to find complete ADB manager state in dumpsys output"
)
return
json_content = content[start_of_json + 2 : end_of_json - 2].rstrip()
# The brace that opens the state and the one that closes it are not part
# of the indented body.
json_content = content[start_of_json + 2 : end_of_json].rstrip()
parsed = self.indented_dump_parser(json_content)
if parsed.get("debugging_manager") is None:
+4
View File
@@ -1279,5 +1279,9 @@
{
"version": "27.0",
"build": "24A437"
},
{
"version": "27.0.1",
"build": "24A446"
}
]
+29
View File
@@ -147,6 +147,12 @@ class Manifest(IOSExtraction):
)
names = [description[0] for description in cur.description]
# An incomplete backup still lists the files it failed to acquire in
# its manifest. Only regular files are stored in the backup folder, so
# directories and symlinks (flags 2 and 4) are not looked up.
stored_file_ids = self._get_stored_backup_file_ids()
missing_files = 0
for file_entry in cur:
file_data = {}
for index, value in enumerate(file_entry):
@@ -160,6 +166,22 @@ class Manifest(IOSExtraction):
"created": "",
}
if (
stored_file_ids is not None
and file_data["flags"] == 1
and file_data["fileID"] not in stored_file_ids
):
# Without this, a module which found nothing for one of these
# files would look like a negative result rather than a gap in
# the acquisition.
cleaned_metadata["missing"] = True
missing_files += 1
self.log.debug(
"File %s is listed in the manifest but was not found in the "
"backup folder",
cleaned_metadata["relative_path"],
)
if file_data["file"]:
try:
file_plist = plistlib.load(io.BytesIO(file_data["file"]))
@@ -197,3 +219,10 @@ class Manifest(IOSExtraction):
conn.close()
self.log.info("Extracted a total of %d file metadata items", len(self.results))
if missing_files:
self.log.info(
"Found %d files listed in the manifest but missing from the backup "
"folder. The backup might be incomplete.",
missing_files,
)
+49
View File
@@ -216,6 +216,55 @@ class IOSExtraction(MVTModule):
return None
def _get_stored_backup_file_ids(self) -> Optional[set[str]]:
"""List the IDs of the files actually stored in the backup folder.
A backup folder stores each file under a two character subfolder named
after the first two characters of its file ID. Walking the folders once
is cheaper than a filesystem lookup per file ID, and it keeps callers
which compare a whole manifest against the folder from paying a
`resolve()` for every entry.
:returns: The file IDs found at their expected paths within the backup
folder, or None if the inventory could not be completed.
"""
if not self.target_path:
return None
file_ids: set[str] = set()
try:
backup_root = Path(self.target_path).resolve()
with os.scandir(self.target_path) as entries:
for entry in entries:
if len(entry.name) != 2 or any(
char not in "0123456789abcdef" for char in entry.name
):
continue
if not entry.is_dir():
continue
if not Path(entry.path).resolve().is_relative_to(backup_root):
continue
with os.scandir(entry.path) as sub_entries:
for sub_entry in sub_entries:
if sub_entry.name[:2] != entry.name:
continue
if sub_entry.is_symlink() and not Path(
sub_entry.path
).resolve().is_relative_to(backup_root):
continue
if sub_entry.is_file():
file_ids.add(sub_entry.name)
except OSError as exc:
self.log.warning(
"Unable to list the files stored in the backup folder %s: %s. "
"Skipping the missing-file check.",
self.target_path,
exc,
)
return None
return file_ids
def _get_fs_files_from_patterns(self, root_paths: list) -> Iterator[str]:
if not self.target_path:
return
+6 -2
View File
@@ -82,7 +82,9 @@ class Filesystem(IOSExtraction):
try:
dir_path = os.path.join(root, dir_name)
result = {
"path": os.path.relpath(dir_path, self.target_path),
"path": os.path.relpath(dir_path, self.target_path).replace(
os.sep, "/"
),
"modified": convert_unix_to_iso(os.stat(dir_path).st_mtime),
}
except Exception:
@@ -94,7 +96,9 @@ class Filesystem(IOSExtraction):
try:
file_path = os.path.join(root, file_name)
result = {
"path": os.path.relpath(file_path, self.target_path),
"path": os.path.relpath(file_path, self.target_path).replace(
os.sep, "/"
),
"modified": convert_unix_to_iso(os.stat(file_path).st_mtime),
}
except Exception:
@@ -130,6 +130,101 @@ class TestDumpsysADBArtifact:
assert key_store_entry["fingerprint"] == expected_fingerprint
assert key_store_entry["last_connected"] == "1628501829898"
def test_parsing_adb_xml_with_crlf_line_endings(self):
da_adb = DumpsysADBArtifact()
file = get_artifact("android_data/dumpsys_adb_xml.txt")
with open(file, "rb") as f:
data = f.read().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
da_adb.parse(data)
assert len(da_adb.results) == 1
assert da_adb.results[0]["user_keys"][0]["user"] == "user@laptop"
assert da_adb.results[0]["keystore"][0]["last_connected"] == "1628501829898"
def test_parsing_adb_wifi_with_mixed_line_endings(self):
da_adb = DumpsysADBArtifact()
data = (
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=true\n"
b" user_keys=QUJDRA== host@example\n"
b" adb_wifi={\n"
b" enabled=false\n"
b" }\n"
b" }\r\n"
b"}\n"
b"--------- duration\n"
)
da_adb.parse(data)
assert len(da_adb.results) == 1
assert da_adb.results[0]["user_keys"][0]["user"] == "host@example"
assert da_adb.results[0]["adb_wifi"]["enabled"] == b"false"
ADB_STATE = (
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=true\n"
b" user_keys=QUJDRA== host@example\n"
b" }\n"
b"}\n"
b"--------- 0.5s was the duration of 'dumpsys adb'\n"
)
def test_a_later_dumpsys_section_does_not_extend_the_adb_state(self):
# A bug report holds many sections. Looking for the last closing brace
# in the whole output pulled a later section into this one, which threw
# IndexError out of parse() and lost the ADB records entirely.
da_adb = DumpsysADBArtifact()
da_adb.parse(
self.ADB_STATE + b"DUMP OF SERVICE other:\n"
b" debugging_manager={\n"
b" connected_to_adb=false\n"
b" user_keys=RVZJTA== attacker@host\n"
b" }\n"
b"}\n"
)
assert len(da_adb.results) == 1
assert [key["user"] for key in da_adb.results[0]["user_keys"]] == [
"host@example"
]
assert da_adb.results[0]["connected_to_adb"] is True
def test_braces_in_key_comment_are_not_state_delimiters(self):
for user in (b"host{example", b"host}}example"):
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=true\n"
b" user_keys=QUJDRA== " + user + b"\n }\n}\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["user_keys"][0]["user"] == user.decode()
def test_unbalanced_state_is_reported_rather_than_raising(self):
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=true\n"
b" }\n"
b" }\n"
b"}\n"
)
# No exception, and whatever was read before the bad line is kept.
assert len(da_adb.results) == 1
assert da_adb.results[0]["connected_to_adb"] is True
class TestDumpsysADBStateAlerts:
def test_no_androidqf_context_preserves_existing_behavior(self):
+2 -1
View File
@@ -1,3 +1,4 @@
import shlex
from types import SimpleNamespace
import click
@@ -110,7 +111,7 @@ def test_load_command_option_supports_folders_and_repeated_paths(tmp_path):
def test_loaded_command_participates_in_shell_completion(tmp_path):
command_path = _write_command(tmp_path / "hello.py", "hello")
group = _make_group()
words = f"group --load-command {command_path} he"
words = f"group --load-command {shlex.quote(str(command_path))} he"
result = CliRunner().invoke(
group,
+12 -8
View File
@@ -1,4 +1,5 @@
import json
import re
from types import SimpleNamespace
import pytest
@@ -54,20 +55,23 @@ def _run(command, arguments):
return CliRunner().invoke(command, arguments, env={"COLUMNS": "200"})
def _table_rows(output):
"""Return the content of the table rows, without the header and the box."""
def _table_lines(output):
"""Return the cells of each line of the table, header first."""
return [
[cell.strip() for cell in line.strip().strip("│").split("│")]
[cell.strip() for cell in re.split("[│┃]", line.strip().strip("│┃"))]
for line in output.splitlines()
if "│" in line
if "│" in line or "┃" in line
]
def _table_rows(output):
"""Return the content of the table rows, without the header and the box."""
return _table_lines(output)[1:]
def _table_header(output):
for line in output.splitlines():
if "┃" in line:
return [cell.strip() for cell in line.strip().strip("┃").split("┃")]
return []
lines = _table_lines(output)
return lines[0] if lines else []
def _install(monkeypatch, distributions, entry_points):
+23 -11
View File
@@ -7,6 +7,7 @@ import logging
import threading
from pathlib import Path
import pytest
from Crypto.Cipher import AES
from mvt.ios.decrypt import DecryptBackup, MVTEncryptedBackup
@@ -81,7 +82,10 @@ def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path):
assert output_path.read_bytes() == b"plain content"
def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_path):
@pytest.mark.parametrize("with_symlink", [False, True], ids=["file-id", "symlink"])
def test_process_backup_rejects_unsafe_file_ids_and_destinations(
mocker, tmp_path, with_symlink
):
backup_path = tmp_path / "backup"
destination = tmp_path / "destination"
outside = tmp_path / "outside"
@@ -92,20 +96,27 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat
safe_file_id = "ef" + "3" * 38
unsafe_file_id = "../../outside-file"
symlink_file_id = "ab" + "4" * 38
for file_id in (safe_file_id, symlink_file_id):
file_ids = [safe_file_id]
if with_symlink:
file_ids.append(symlink_file_id)
for file_id in file_ids:
source_path = backup_path / file_id[:2] / file_id
source_path.parent.mkdir(parents=True, exist_ok=True)
source_path.write_bytes(b"encrypted")
(destination / "ab").symlink_to(outside, target_is_directory=True)
if with_symlink:
try:
(destination / "ab").symlink_to(outside, target_is_directory=True)
except OSError:
pytest.skip("creating symbolic links is not permitted on this system")
cursor = mocker.MagicMock()
cursor.__iter__.return_value = iter(
[
(safe_file_id, "Domain", "safe", b"plist"),
(unsafe_file_id, "Domain", "unsafe", b"plist"),
(symlink_file_id, "Domain", "symlink", b"plist"),
]
)
records = [
(safe_file_id, "Domain", "safe", b"plist"),
(unsafe_file_id, "Domain", "unsafe", b"plist"),
]
if with_symlink:
records.append((symlink_file_id, "Domain", "symlink", b"plist"))
cursor.__iter__.return_value = iter(records)
cursor_context = mocker.MagicMock()
cursor_context.__enter__.return_value = cursor
@@ -124,7 +135,8 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat
decryptor.process_backup()
assert (destination / safe_file_id[:2] / safe_file_id).read_bytes() == b"decrypted"
assert not (outside / symlink_file_id).exists()
if with_symlink:
assert not (outside / symlink_file_id).exists()
backup.extract_file_by_id.assert_called_once()
assert backup.extract_file_by_id.call_args.kwargs["file_id"] == safe_file_id
+152
View File
@@ -5,7 +5,12 @@
import gc
import logging
import os
import shutil
import warnings
from pathlib import Path
import pytest
from mvt.common.indicators import Indicators
from mvt.common.module import run_module
@@ -14,8 +19,58 @@ from mvt.ios.modules.backup.manifest import Manifest
from ..utils import get_ios_backup_folder
# fileID of HomeDomain::Library/SMS/sms.db in the test backup. It is one of the
# few files the test backup actually stores.
SMS_FILE_ID = "3d0d7e5fb2ce288813306e4d4636395e047a3d28"
@pytest.fixture
def backup_without_stored_files(tmp_path):
"""A copy of the test backup with every stored file removed.
The test backup only ships a handful of the files its manifest lists, so
dropping what it does store leaves a backup where every regular file the
manifest mentions is missing from the folder.
"""
backup_path = tmp_path / "backup"
shutil.copytree(get_ios_backup_folder(), backup_path)
for file_id_folder in backup_path.iterdir():
if not file_id_folder.is_dir():
continue
for backup_file in file_id_folder.iterdir():
backup_file.unlink()
return str(backup_path)
class TestIOSExtraction:
@pytest.mark.parametrize("link_directory", [False, True], ids=["file", "directory"])
@pytest.mark.parametrize("inside_backup", [False, True], ids=["outside", "inside"])
def test_stored_file_inventory_matches_symlink_resolution(
self, tmp_path, link_directory, inside_backup
):
backup_path = tmp_path / "backup"
backup_path.mkdir()
destination = (backup_path if inside_backup else tmp_path) / "contents"
destination.mkdir()
(destination / SMS_FILE_ID).write_bytes(b"backup file")
bucket = backup_path / SMS_FILE_ID[:2]
try:
if link_directory:
bucket.symlink_to(destination, target_is_directory=True)
else:
bucket.mkdir()
(bucket / SMS_FILE_ID).symlink_to(destination / SMS_FILE_ID)
except OSError:
pytest.skip("creating symbolic links is not permitted on this system")
m = IOSExtraction(target_path=str(backup_path))
assert bool(m._get_backup_file_from_id(SMS_FILE_ID)) is inside_backup
assert m._get_stored_backup_file_ids() == (
{SMS_FILE_ID} if inside_backup else set()
)
def test_get_backup_files_from_manifest_closes_connection(self):
m = IOSExtraction(target_path=get_ios_backup_folder())
@@ -41,6 +96,103 @@ class TestManifestModule:
assert len(m.timeline) == 5881
assert len(m.alertstore.alerts) == 0
def test_manifest_flags_the_files_missing_from_an_incomplete_backup(self):
m = Manifest(target_path=get_ios_backup_folder())
run_module(m)
missing = [result for result in m.results if result.get("missing")]
# The test backup only stores a handful of the files its manifest
# lists, and every one of the rest is a regular file (flags 1).
assert len(missing) == 1079
assert all(result["flags"] == 1 for result in missing)
stored = [result for result in m.results if result["file_id"] == SMS_FILE_ID]
assert len(stored) == 1
assert "missing" not in stored[0]
def test_manifest_flags_every_stored_file_removed_from_the_backup_folder(
self, backup_without_stored_files
):
m = Manifest(target_path=backup_without_stored_files)
run_module(m)
missing = [result for result in m.results if result.get("missing")]
assert len(missing) == 1089
def test_manifest_flags_a_file_removed_from_the_backup_folder(self, tmp_path):
backup_path = tmp_path / "backup"
shutil.copytree(get_ios_backup_folder(), backup_path)
(backup_path / SMS_FILE_ID[:2] / SMS_FILE_ID).unlink()
m = Manifest(target_path=str(backup_path))
run_module(m)
removed = [result for result in m.results if result["file_id"] == SMS_FILE_ID]
assert len(removed) == 1
assert removed[0]["missing"] is True
@pytest.mark.parametrize("failed_folder", ["", "3d"], ids=["root", "bucket"])
def test_manifest_skips_missing_check_when_inventory_fails(
self, monkeypatch, caplog, failed_folder
):
backup_path = Path(get_ios_backup_folder())
failed_path = backup_path / failed_folder
scandir = os.scandir
def fail_listing(path):
if Path(path) == failed_path:
raise PermissionError("cannot list backup folder")
return scandir(path)
monkeypatch.setattr(os, "scandir", fail_listing)
m = Manifest(target_path=str(backup_path))
with caplog.at_level(logging.INFO):
m.run()
assert len(m.results) == 3721
assert m._get_backup_file_from_id(SMS_FILE_ID) is not None
assert all("missing" not in result for result in m.results)
assert "Skipping the missing-file check" in caplog.text
assert "The backup might be incomplete" not in caplog.text
def test_manifest_ignores_unreadable_unrelated_folders(self, tmp_path, monkeypatch):
backup_path = tmp_path / "backup"
shutil.copytree(get_ios_backup_folder(), backup_path)
unrelated = backup_path / "notes"
unrelated.mkdir()
scandir = os.scandir
def fail_listing(path):
if Path(path) == unrelated:
raise PermissionError("cannot list unrelated folder")
return scandir(path)
monkeypatch.setattr(os, "scandir", fail_listing)
m = Manifest(target_path=str(backup_path))
m.run()
assert sum(bool(result.get("missing")) for result in m.results) == 1079
stored = next(
result for result in m.results if result["file_id"] == SMS_FILE_ID
)
assert "missing" not in stored
@pytest.mark.parametrize("wrong_folder", ["ab", "notes"])
def test_manifest_flags_files_in_the_wrong_folder(self, tmp_path, wrong_folder):
backup_path = tmp_path / "backup"
shutil.copytree(get_ios_backup_folder(), backup_path)
destination = backup_path / wrong_folder
destination.mkdir(exist_ok=True)
(backup_path / SMS_FILE_ID[:2] / SMS_FILE_ID).rename(destination / SMS_FILE_ID)
m = Manifest(target_path=str(backup_path))
m.run()
assert m._get_backup_file_from_id(SMS_FILE_ID) is None
moved = next(result for result in m.results if result["file_id"] == SMS_FILE_ID)
assert moved["missing"] is True
assert sum(bool(result.get("missing")) for result in m.results) == 1080
def test_detection(self, indicator_file):
m = Manifest(target_path=get_ios_backup_folder())
ind = Indicators(log=logging.getLogger())
+5
View File
@@ -9,6 +9,8 @@ import plistlib
import shutil
import sqlite3
import pytest
from mvt.ios.modules.base import IOSExtraction
from mvt.ios.modules.fs.analytics import Analytics
@@ -45,6 +47,9 @@ def test_open_sqlite_reads_wal_without_modifying_evidence(tmp_path):
assert not os.path.exists(str(evidence_path) + "-shm")
@pytest.mark.skipif(
shutil.which("sqlite3") is None, reason="the recovery needs the sqlite3 binary"
)
def test_recovery_preserves_source_database(tmp_path):
database_path = tmp_path / "source.db"
conn = sqlite3.connect(database_path)
+30
View File
@@ -3,15 +3,45 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
import ntpath
from types import SimpleNamespace
from mvt.common.indicators import Indicators
from mvt.common.module import run_module
from mvt.ios.modules.fs import filesystem
from mvt.ios.modules.fs.filesystem import Filesystem
from ..utils import get_ios_backup_folder
class TestFilesystem:
def test_windows_paths_are_normalized_for_indicators(
self, monkeypatch, indicators_factory
):
m = Filesystem(target_path=r"C:\dump")
m.indicators = indicators_factory(
file_paths=["matched/directory"], processes=["matched"]
)
monkeypatch.setattr(
filesystem,
"os",
SimpleNamespace(
sep="\\",
path=ntpath,
walk=lambda _: [(r"C:\dump\matched", ["directory"], ["file.txt"])],
stat=lambda _: SimpleNamespace(st_mtime=0),
),
)
m.run()
m.check_indicators()
assert {result["path"] for result in m.results} == {
"matched/directory",
"matched/file.txt",
}
assert len(m.alertstore.alerts) == 3
def test_filesystem(self):
m = Filesystem(target_path=get_ios_backup_folder())
run_module(m)
+1 -1
View File
@@ -85,7 +85,7 @@ class TestCheckAndroidqfCommand:
def test_acquisition_context_falls_back_to_public_key_file(self, tmp_path):
data_path = tmp_path / "androidqf"
data_path.mkdir()
(data_path / "adb_host_key.pub").write_text("QUJDRA== acquisition@host\n")
(data_path / "adb_host_key.pub").write_bytes(b"QUJDRA== acquisition@host\n")
command = CmdAndroidCheckAndroidQF(target_path=str(data_path))
command.init()
+1 -1
View File
@@ -116,7 +116,7 @@ def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path):
module = SysdiagnoseExtraction()
command.module_init(module)
assert module.tar is None
assert module.parent_path == str(extracted_path.parent)
assert Path(module.parent_path) == extracted_path.parent
finally:
command.finish()
+2
View File
@@ -43,6 +43,7 @@ class TestCompletionCommand:
def test_completion_install_updates_bashrc_once(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
runner = CliRunner()
result = runner.invoke(mvt_cli, ["completion", "bash", "--install"])
@@ -67,6 +68,7 @@ class TestCompletionCommand:
self, tmp_path, monkeypatch
):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
runner = CliRunner()
result = runner.invoke(mvt_cli, ["completion", "fish", "--install"])