mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-22 23:28:23 +02:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0258c990ff | ||
|
|
fd37c4d8d9 | ||
|
|
c615d5e304 | ||
|
|
9bdc25f57d | ||
|
|
8238d80035 | ||
|
|
22d97f0a88 | ||
|
|
d224db5a78 | ||
|
|
658c7aa327 | ||
|
|
9316ac7fb0 | ||
|
|
6ddb2c9671 | ||
|
|
165d87b97f | ||
|
|
d2e992e40e | ||
|
|
aa3ae511ad | ||
|
|
7700749cfa | ||
|
|
6eb1a53873 | ||
|
|
02aa23cb81 | ||
|
|
9c3db579ee | ||
|
|
983e970443 | ||
|
|
cfaeb99bf7 | ||
|
|
00a5f11124 | ||
|
|
b1b9958f4d | ||
|
|
ad6caa155f | ||
|
|
c2030e6e84 | ||
|
|
99b3b5f014 | ||
|
|
9ca4254649 | ||
|
|
f1e52b297a | ||
|
|
8c5278f540 | ||
|
|
df3b130ca8 | ||
|
|
3bb652f2d2 | ||
|
|
c399608345 | ||
|
|
7f19d96297 | ||
|
|
ff5ebf73cc | ||
|
|
bdbc07a3b3 | ||
|
|
6f65eee85a | ||
|
|
777db67291 | ||
|
|
a0bc233eec | ||
|
|
1db5d239d0 | ||
|
|
2426178baa | ||
|
|
e0697a58e9 | ||
|
|
d0fc0379b9 | ||
|
|
7973e90589 | ||
|
|
935747519f | ||
|
|
178ac52aab | ||
|
|
f20fd14075 | ||
|
|
a9d787c446 | ||
|
|
518becb825 | ||
|
|
a3d289c709 | ||
|
|
b4b0a06470 | ||
|
|
eceafdc785 | ||
|
|
ffae240355 | ||
|
|
3623e430f4 | ||
|
|
60e652bafc | ||
|
|
92f780df5a | ||
|
|
2eb40b85cf | ||
|
|
b9055e365c | ||
|
|
fc217e9608 | ||
|
|
d7148c03b4 | ||
|
|
0ddcab904d | ||
|
|
858496e60b | ||
|
|
e5fced2a08 | ||
|
|
fe505c63a6 | ||
|
|
25a571c462 | ||
|
|
0e231eefaf |
No files matched your search
@@ -34,6 +34,7 @@ jobs:
|
||||
publish:
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi # only tag refs may deploy; the PyPI trusted publisher requires it
|
||||
permissions:
|
||||
id-token: write # PyPI trusted publishing
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
prune tests
|
||||
prune docs
|
||||
prune .github
|
||||
exclude uv.lock Dockerfile* mkdocs.yml .readthedocs.yaml .safety-policy.yml .python-version .gitignore Makefile
|
||||
@@ -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*.
|
||||
@@ -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.
|
||||
|
||||
+1
-3
@@ -24,10 +24,8 @@ dependencies = [
|
||||
"simplejson==4.1.1",
|
||||
"packaging==26.3",
|
||||
"appdirs==1.4.4",
|
||||
"iphone_backup_decrypt==0.9.0",
|
||||
"iphone_backup_decrypt==0.10.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
"adb-shell[usb]==0.4.4",
|
||||
"libusb1==3.4.0",
|
||||
"cryptography==50.0.0",
|
||||
"PyYAML>=6.0.2",
|
||||
"pyahocorasick==2.3.1",
|
||||
|
||||
@@ -10,8 +10,45 @@ from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
# One list for both record shapes — a service record and a count-only
|
||||
# record must stay the same shape.
|
||||
_FIELDS = (
|
||||
"user_id",
|
||||
"component",
|
||||
"package_name",
|
||||
"service_name",
|
||||
"installed",
|
||||
"enabled",
|
||||
"binding",
|
||||
"bound",
|
||||
"crashed",
|
||||
"accessibility_tool",
|
||||
"installed_service_count",
|
||||
"unnamed_service_count",
|
||||
)
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for result in self.results:
|
||||
# A stated count the dump does not back with component names is a
|
||||
# coverage statement, not a service: low, but not silent.
|
||||
if not result.get("component"):
|
||||
stated = result["installed_service_count"]
|
||||
unnamed = result["unnamed_service_count"]
|
||||
if unnamed == stated:
|
||||
detail = "does not list their component names"
|
||||
else:
|
||||
detail = (
|
||||
f"lists the component names of only {stated - unnamed} "
|
||||
f"of them ({unnamed} unnamed)"
|
||||
)
|
||||
self.alertstore.low(
|
||||
f"The accessibility dump states {stated} installed "
|
||||
f"service(s) for user {result['user_id']} but {detail}",
|
||||
"",
|
||||
result,
|
||||
)
|
||||
continue
|
||||
|
||||
if self.indicators:
|
||||
ioc_match = self.indicators.check_app_id(result["package_name"])
|
||||
if ioc_match:
|
||||
@@ -20,11 +57,22 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
)
|
||||
continue
|
||||
|
||||
self.alertstore.medium(
|
||||
f'Found accessibility service: "{result["component"]}"',
|
||||
"",
|
||||
result,
|
||||
# Installed is not enabled. A service can sit installed for years
|
||||
# without ever being switched on, and that is the difference this
|
||||
# channel is read for — an alert that says only "found" makes every
|
||||
# device look equally exposed. A service the dump says is switched
|
||||
# OFF is reported LOW, so it still reaches the analyst without
|
||||
# competing with one that is actually running; a dump that does not
|
||||
# state the enabled state stays MEDIUM, because "not stated" is not
|
||||
# "not enabled".
|
||||
message = (
|
||||
f'Found accessibility service: "{result["component"]}" '
|
||||
f"({self._describe_state(result)})"
|
||||
)
|
||||
if result.get("enabled") is False and not result.get("bound"):
|
||||
self.alertstore.low(message, "", result)
|
||||
else:
|
||||
self.alertstore.medium(message, "", result)
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
"""
|
||||
@@ -36,6 +84,13 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
|
||||
self.results: list[dict[str, Any]] = []
|
||||
services: dict[tuple[int | None, str], dict] = {}
|
||||
# Which state sections the dump printed, per user: one user's printed
|
||||
# `enabled services` says nothing about another user's.
|
||||
seen_states: dict[int | None, set[str]] = {}
|
||||
# `installedServiceCount=N` from the user's `attributes:{…}` line is on
|
||||
# most builds the only statement about installed services in the dump:
|
||||
# few print the `installed services: {…}` block.
|
||||
installed_counts: dict[int | None, int] = {}
|
||||
user_id: int | None = None
|
||||
state: str | None = None
|
||||
|
||||
@@ -44,6 +99,10 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
if user_match:
|
||||
user_id = int(user_match.group(1))
|
||||
|
||||
count_match = re.search(r"installedServiceCount=(\d+)", line)
|
||||
if count_match:
|
||||
installed_counts[user_id] = int(count_match.group(1))
|
||||
|
||||
stripped = line.strip()
|
||||
state_match = re.match(
|
||||
r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)",
|
||||
@@ -51,6 +110,7 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
)
|
||||
if state_match:
|
||||
state = state_match.group(1).lower()
|
||||
seen_states.setdefault(user_id, set()).add(self._state_field(state))
|
||||
inline = state_match.group(2)
|
||||
for component in re.findall(
|
||||
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
|
||||
@@ -79,24 +139,65 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
service[self._state_field(state)] = True
|
||||
service["accessibility_tool"] = "(A11yTool)" in stripped
|
||||
|
||||
# A section that was never printed is NOT the same as one printed
|
||||
# empty: the first says nothing, the second says nothing is enabled.
|
||||
# Defaulting every flag to False would turn "not stated" into "not
|
||||
# enabled". Flags for sections this dump never printed stay None.
|
||||
named: dict[int | None, int] = {}
|
||||
for (service_user, _component), service in services.items():
|
||||
printed = seen_states.get(service_user, set())
|
||||
for state in ("installed", "enabled", "binding", "bound", "crashed"):
|
||||
if self._state_field(state) not in printed:
|
||||
service[self._state_field(state)] = None
|
||||
service["installed_service_count"] = installed_counts.get(service_user)
|
||||
named[service_user] = named.get(service_user, 0) + 1
|
||||
|
||||
self.results.extend(services.values())
|
||||
|
||||
# A stated count the named services do not add up to would leave no
|
||||
# trace of the rest: the module would log "a total of 0" about a dump
|
||||
# that said five, or "a total of 1" about one that said two.
|
||||
for count_user, count in installed_counts.items():
|
||||
unnamed = count - named.get(count_user, 0)
|
||||
if unnamed > 0:
|
||||
self.results.append(self._new_unlisted(count_user, count, unnamed))
|
||||
|
||||
@staticmethod
|
||||
def _describe_state(result: dict) -> str:
|
||||
if result.get("bound"):
|
||||
return "enabled and bound"
|
||||
if result.get("enabled"):
|
||||
return "enabled"
|
||||
if result.get("enabled") is None:
|
||||
return "installed, enabled state not stated"
|
||||
return "installed, not enabled"
|
||||
|
||||
@staticmethod
|
||||
def _state_field(state: str) -> str:
|
||||
return {"binding": "binding", "bound": "bound"}.get(state, state)
|
||||
|
||||
@staticmethod
|
||||
def _new_unlisted(user_id: int | None, count: int, unnamed: int) -> dict:
|
||||
"""The dump's own count for a user, and how many of those services it
|
||||
did not name.
|
||||
|
||||
Every other field stays unknown: the dump named no service to carry it.
|
||||
"""
|
||||
record: dict[str, Any] = dict.fromkeys(DumpsysAccessibilityArtifact._FIELDS)
|
||||
record["user_id"] = user_id
|
||||
record["installed_service_count"] = count
|
||||
record["unnamed_service_count"] = unnamed
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def _new_service(component: str, user_id: int | None) -> dict:
|
||||
package_name, service_name = component.split("/", 1)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"component": component,
|
||||
"package_name": package_name,
|
||||
"service_name": service_name,
|
||||
"installed": False,
|
||||
"enabled": False,
|
||||
"binding": False,
|
||||
"bound": False,
|
||||
"crashed": False,
|
||||
"accessibility_tool": False,
|
||||
}
|
||||
record: dict[str, Any] = dict.fromkeys(
|
||||
DumpsysAccessibilityArtifact._FIELDS, False
|
||||
)
|
||||
record["user_id"] = user_id
|
||||
record["component"] = component
|
||||
record["package_name"], record["service_name"] = component.split("/", 1)
|
||||
# Filled in after parsing: the count is per user.
|
||||
record["installed_service_count"] = None
|
||||
record["unnamed_service_count"] = None
|
||||
return record
|
||||
@@ -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:
|
||||
|
||||
@@ -73,8 +73,12 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
line = line.strip().replace("Update ", "")
|
||||
package_name, vers = line.split(" ", 1)
|
||||
vers_raw = vers.split("=", 1)[1]
|
||||
# A truncated or vendor-specific line must not abort the parse and
|
||||
# lose every record after it.
|
||||
package_name, _, vers = line.partition(" ")
|
||||
vers_raw = vers.partition("=")[2]
|
||||
if not package_name or not vers_raw:
|
||||
continue
|
||||
try:
|
||||
version_code: int | str = int(vers_raw)
|
||||
except ValueError:
|
||||
|
||||
@@ -136,6 +136,10 @@ class Mounts(AndroidArtifact):
|
||||
mount_options = fields[5].split(",")
|
||||
super_options = fields[separator + 3].split(",")
|
||||
options = list(dict.fromkeys(mount_options + super_options))
|
||||
# fields[5] are the per-mount (VFS) flags, the field after the
|
||||
# separator the superblock's; a mount is writable only if both
|
||||
# allow it. The merged list made every read-only overlay a HIGH.
|
||||
is_read_write = "rw" in mount_options and "rw" in super_options
|
||||
mount_point = fields[4].replace("\\040", " ")
|
||||
device = fields[separator + 2].replace("\\040", " ")
|
||||
filesystem_type = fields[separator + 1]
|
||||
@@ -156,7 +160,7 @@ class Mounts(AndroidArtifact):
|
||||
"options_list": options,
|
||||
"optional_fields": fields[6:separator],
|
||||
"is_system_partition": is_system,
|
||||
"is_read_write": "rw" in options,
|
||||
"is_read_write": is_read_write,
|
||||
"process_ids": [process_id],
|
||||
}
|
||||
)
|
||||
@@ -191,7 +195,14 @@ class Mounts(AndroidArtifact):
|
||||
)
|
||||
|
||||
# Check for other suspicious mount options
|
||||
suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS]
|
||||
# `rw` sitting in the merged option list is not evidence of
|
||||
# writability (see is_read_write in parse_mountinfo); every other
|
||||
# suspicious option is meaningful in either list.
|
||||
suspicious_opts = [
|
||||
opt
|
||||
for opt in options
|
||||
if opt in SUSPICIOUS_OPTIONS and (opt != "rw" or mount["is_read_write"])
|
||||
]
|
||||
if suspicious_opts and mount["is_system_partition"]:
|
||||
if (
|
||||
"noatime" in mount["mount_options"]
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
@@ -60,45 +65,240 @@ ANDROID_DANGEROUS_SETTINGS = [
|
||||
},
|
||||
]
|
||||
|
||||
# dumpsys prints the fields of a setting record, and of a change history entry,
|
||||
# always in this order and separated by a single space. After the value come
|
||||
# `default:` and `defaultSystemSet:` when a default is recorded, then `tag:`;
|
||||
# some vendor builds add whether the value survives a restore, either as
|
||||
# `isValuePreservedInRestore:` or as a bare `notPreservedInRestore` token.
|
||||
SETTING_FIELDS = (
|
||||
"_id",
|
||||
"name",
|
||||
"pkg",
|
||||
"value",
|
||||
"default",
|
||||
"defaultSystemSet",
|
||||
"tag",
|
||||
"isValuePreservedInRestore",
|
||||
)
|
||||
HISTORY_FIELDS = ("time", "mode", "oldValue", "newValue", "package")
|
||||
|
||||
NAMESPACE_PATTERN = re.compile(
|
||||
r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$"
|
||||
)
|
||||
SECTION_END_PATTERN = re.compile(r"ending at: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
|
||||
|
||||
|
||||
class Settings(AndroidArtifact):
|
||||
def parse(self, content: str) -> None:
|
||||
self.results: dict[str, dict[str, str]] = {}
|
||||
namespace: str | None = None
|
||||
for line in content.splitlines():
|
||||
heading = re.match(
|
||||
r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$",
|
||||
line.strip(),
|
||||
)
|
||||
if heading:
|
||||
namespace = f"{heading.group(1).lower()}:user_{heading.group(2)}"
|
||||
self.results[namespace] = {}
|
||||
"""Parser for the `dumpsys settings` output.
|
||||
|
||||
Every row of the settings provider becomes one result, keeping the fields
|
||||
dumpsys prints alongside the value: the row id, the package which recorded
|
||||
the setting, the default, the tag, and the change history. A setting name
|
||||
can appear more than once within a namespace, so results are a list rather
|
||||
than a mapping.
|
||||
"""
|
||||
|
||||
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
for entry in result.get("history", []):
|
||||
if not entry.get("timestamp"):
|
||||
continue
|
||||
if namespace is None or not line.startswith("_id:"):
|
||||
continue
|
||||
setting = re.match(
|
||||
r"^_id:\S+\s+name:(.*?)\s+pkg:.*?\s+value:(.*?)"
|
||||
r"(?:\s+default:.*\s+defaultSystemSet:(?:true|false))?$",
|
||||
line,
|
||||
|
||||
records.append(
|
||||
{
|
||||
"timestamp": entry["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "settings_change",
|
||||
"data": (
|
||||
f"{result.get('namespace')} setting "
|
||||
f'"{result.get("name")}" changed from '
|
||||
f'"{entry.get("oldValue")}" to "{entry.get("newValue")}" '
|
||||
f"by {entry.get('pkg')}"
|
||||
),
|
||||
}
|
||||
)
|
||||
if setting:
|
||||
self.results[namespace][setting.group(1)] = setting.group(2)
|
||||
|
||||
return records
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for namespace, settings in self.results.items():
|
||||
for key, value in settings.items():
|
||||
for danger in ANDROID_DANGEROUS_SETTINGS:
|
||||
# Check if one of the dangerous settings is using an unsafe
|
||||
# value (different than the one specified).
|
||||
if danger["key"] == key and danger["safe_value"] != value:
|
||||
self.alertstore.medium(
|
||||
f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})',
|
||||
"",
|
||||
{
|
||||
"namespace": namespace,
|
||||
"key": key,
|
||||
"value": value,
|
||||
"description": danger["description"],
|
||||
},
|
||||
)
|
||||
break
|
||||
for result in self.results:
|
||||
name = result.get("name")
|
||||
value = result.get("value")
|
||||
for danger in ANDROID_DANGEROUS_SETTINGS:
|
||||
# Check if one of the dangerous settings is using an unsafe
|
||||
# value (different than the one specified).
|
||||
if danger["key"] != name or danger["safe_value"] == value:
|
||||
continue
|
||||
|
||||
history = result.get("history") or []
|
||||
self.alertstore.medium(
|
||||
f'Found suspicious "{result.get("namespace")}" setting '
|
||||
f'"{name} = {value}" ({danger["description"]})',
|
||||
history[-1]["timestamp"] if history else "",
|
||||
result,
|
||||
)
|
||||
break
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
self.results: list[ModuleAtomicResult] = []
|
||||
section_end = self._parse_section_end(content)
|
||||
namespace: Optional[str] = None
|
||||
user: Optional[str] = None
|
||||
record_lines: list[str] = []
|
||||
history_lines: list[str] = []
|
||||
in_history = False
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal record_lines, history_lines, in_history
|
||||
if record_lines:
|
||||
self.results.append(
|
||||
self._build_record(
|
||||
namespace, user, record_lines, history_lines, section_end
|
||||
)
|
||||
)
|
||||
record_lines = []
|
||||
history_lines = []
|
||||
in_history = False
|
||||
|
||||
for line in content.splitlines():
|
||||
heading = NAMESPACE_PATTERN.match(line.strip())
|
||||
if heading:
|
||||
flush()
|
||||
namespace = heading.group(1).lower()
|
||||
user = heading.group(2)
|
||||
continue
|
||||
|
||||
if line.startswith("--------- "):
|
||||
# dumpsys closes every section with a duration trailer.
|
||||
flush()
|
||||
namespace = None
|
||||
continue
|
||||
|
||||
if namespace is None:
|
||||
continue
|
||||
|
||||
if not line.strip():
|
||||
# dumpsys prints a blank line after every namespace block and
|
||||
# after a change history, and other dumps such as the
|
||||
# generation registry follow the last block, so a blank line
|
||||
# closes the record being read.
|
||||
flush()
|
||||
continue
|
||||
|
||||
if line.startswith("_id:"):
|
||||
flush()
|
||||
record_lines = [line]
|
||||
continue
|
||||
|
||||
if not record_lines:
|
||||
continue
|
||||
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("History ("):
|
||||
in_history = True
|
||||
continue
|
||||
|
||||
if in_history:
|
||||
if stripped.startswith("time:"):
|
||||
history_lines.append(stripped)
|
||||
elif stripped and history_lines:
|
||||
# A history entry can be wrapped over several lines.
|
||||
history_lines[-1] += " " + stripped
|
||||
continue
|
||||
|
||||
# Anything else continues the value of the record being read.
|
||||
record_lines.append(line)
|
||||
|
||||
flush()
|
||||
|
||||
@staticmethod
|
||||
def _split_fields(text: str, keys: Sequence[str]) -> dict[str, str]:
|
||||
"""Split the `key:value` fields of one record.
|
||||
|
||||
Values are free-form and may contain spaces and newlines, so a field
|
||||
runs up to the start of the next key which is actually present. Keys
|
||||
dumpsys did not print are skipped.
|
||||
"""
|
||||
fields: dict[str, str] = {}
|
||||
key = keys[0]
|
||||
if not text.startswith(f"{key}:"):
|
||||
return fields
|
||||
|
||||
remainder = text[len(key) + 1 :]
|
||||
for next_key in keys[1:]:
|
||||
value, separator, rest = remainder.partition(f" {next_key}:")
|
||||
if separator:
|
||||
fields[key] = value
|
||||
key, remainder = next_key, rest
|
||||
|
||||
fields[key] = remainder
|
||||
return fields
|
||||
|
||||
@staticmethod
|
||||
def _parse_section_end(content: str) -> Optional[datetime]:
|
||||
"""Return the time the settings section was dumped, if reported."""
|
||||
match = SECTION_END_PATTERN.search(content)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_timestamp(
|
||||
value: str, section_end: Optional[datetime]
|
||||
) -> Optional[str]:
|
||||
"""Add the missing year to a `MM-DD HH:MM:SS.mmm` history timestamp.
|
||||
|
||||
dumpsys prints the change history without a year, so it is resolved
|
||||
against the time the section was dumped: the most recent matching date
|
||||
at or before that time.
|
||||
"""
|
||||
if section_end is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
partial = datetime.strptime(value, "%m-%d %H:%M:%S.%f")
|
||||
timestamp = partial.replace(year=section_end.year)
|
||||
if timestamp > section_end:
|
||||
timestamp = partial.replace(year=section_end.year - 1)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return convert_datetime_to_iso(timestamp)
|
||||
|
||||
def _parse_history(
|
||||
self, line: str, section_end: Optional[datetime]
|
||||
) -> ModuleAtomicResult:
|
||||
fields = self._split_fields(line, HISTORY_FIELDS)
|
||||
return {
|
||||
"timestamp": self._resolve_timestamp(fields.get("time", ""), section_end),
|
||||
"oldValue": fields.get("oldValue"),
|
||||
"newValue": fields.get("newValue"),
|
||||
"pkg": fields.get("package"),
|
||||
}
|
||||
|
||||
def _build_record(
|
||||
self,
|
||||
namespace: Optional[str],
|
||||
user: Optional[str],
|
||||
record_lines: list[str],
|
||||
history_lines: list[str],
|
||||
section_end: Optional[datetime],
|
||||
) -> ModuleAtomicResult:
|
||||
text = "\n".join(record_lines).rstrip()
|
||||
# The bare `notPreservedInRestore` token has no `key:` shape and is
|
||||
# printed last, so peel it off before splitting the fields.
|
||||
head = text.removesuffix(" notPreservedInRestore")
|
||||
|
||||
record: ModuleAtomicResult = {"namespace": namespace, "user": user}
|
||||
record.update(self._split_fields(head, SETTING_FIELDS))
|
||||
if head != text:
|
||||
record["isValuePreservedInRestore"] = "false"
|
||||
|
||||
record["history"] = [
|
||||
self._parse_history(entry, section_end) for entry in history_lines
|
||||
]
|
||||
return record
|
||||
+17
-8
@@ -150,6 +150,16 @@ def check_adb(ctx):
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option(
|
||||
"--timezone",
|
||||
"-t",
|
||||
default=None,
|
||||
help=(
|
||||
"IANA timezone name for the device, for example 'Europe/Paris'. "
|
||||
"Bugreport file timestamps are the device's wall clock; by default the "
|
||||
"zone is read from persist.sys.timezone in the bugreport itself."
|
||||
),
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
@@ -160,6 +170,7 @@ def check_bugreport(
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
timezone,
|
||||
verbose,
|
||||
bugreport_path,
|
||||
):
|
||||
@@ -167,12 +178,18 @@ def check_bugreport(
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
module_options = {}
|
||||
if timezone:
|
||||
module_options["device_timezone"] = timezone
|
||||
|
||||
# Always generate hashes as bug reports are small.
|
||||
cmd = CmdAndroidCheckBugreport(
|
||||
target_path=bugreport_path,
|
||||
results_path=output,
|
||||
ioc_files=iocs,
|
||||
module_name=module,
|
||||
module_options=module_options if module_options else None,
|
||||
hashes=True,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
@@ -183,8 +200,6 @@ def check_bugreport(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking Android bug report at path: %s", bugreport_path)
|
||||
|
||||
try:
|
||||
cmd.run()
|
||||
except BadZipFile as exc:
|
||||
@@ -259,8 +274,6 @@ def check_backup(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking Android backup at path: %s", backup_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
@@ -342,8 +355,6 @@ def check_androidqf(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking AndroidQF acquisition at path: %s", androidqf_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_disable_adb_warning()
|
||||
@@ -424,8 +435,6 @@ def check_intrusion_logs(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking intrusion logs at path: %s", logs_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
@@ -82,6 +82,8 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
if not self.target_path:
|
||||
raise NoAndroidQFTargetPath
|
||||
|
||||
self.log.info("Checking AndroidQF acquisition at path: %s", self.target_path)
|
||||
|
||||
if os.path.isdir(self.target_path):
|
||||
self.__format = "dir"
|
||||
parent_path = Path(self.target_path).absolute().parent.as_posix()
|
||||
|
||||
@@ -129,6 +129,7 @@ class CmdAndroidCheckBackup(Command):
|
||||
assert self.target_path is not None # type: ignore[has-type]
|
||||
# Use a different local variable name to avoid any scoping issues
|
||||
backup_path: str = self.target_path # type: ignore[has-type]
|
||||
self.log.info("Checking Android backup at path: %s", backup_path)
|
||||
|
||||
if os.path.isfile(backup_path):
|
||||
self.__type = "ab"
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import fnmatch
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from mvt.android.modules.bugreport.base import BugReportModule
|
||||
from mvt.android.artifacts.getprop import GetProp
|
||||
from mvt.android.modules.bugreport.base import (
|
||||
DUMPSTATE_ENTRY_POINTS,
|
||||
BugReportModule,
|
||||
)
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
@@ -87,14 +93,100 @@ class CmdAndroidCheckBugreport(Command):
|
||||
for file_name in self.__zip.namelist():
|
||||
self.__files.append(file_name)
|
||||
|
||||
if not self._has_dumpstate(self.__files):
|
||||
nested = self._nested_bugreport(bugreport_zip)
|
||||
if nested:
|
||||
self.__zip = nested
|
||||
self.__files = list(nested.namelist())
|
||||
|
||||
@staticmethod
|
||||
def _has_dumpstate(file_names: List[str]) -> bool:
|
||||
"""Whether these members carry any of the entry points the bug report
|
||||
modules read (see `BugReportModule._get_dumpstate_file`)."""
|
||||
return any(
|
||||
fnmatch.filter(file_names, pattern) for pattern in DUMPSTATE_ENTRY_POINTS
|
||||
)
|
||||
|
||||
def _nested_bugreport(self, outer: ZipFile) -> Optional[ZipFile]:
|
||||
"""Descend one level into an OEM wrapper archive.
|
||||
|
||||
MIUI / HyperOS hands out a zip of app logs, ANR traces and tcpdump
|
||||
captures with the real `bugreport-<device>-<timestamp>.zip` nested
|
||||
inside. Without this descent the outer archive has no entry point,
|
||||
every module reports it found no files, and the command still exits 0
|
||||
with an empty result.
|
||||
"""
|
||||
candidates = [
|
||||
name for name in outer.namelist() if name.lower().endswith(".zip")
|
||||
]
|
||||
candidates.sort(
|
||||
key=lambda name: (
|
||||
"bugreport" not in name.lower() and "dumpstate" not in name.lower()
|
||||
)
|
||||
)
|
||||
for name in candidates:
|
||||
try:
|
||||
inner = ZipFile(io.BytesIO(outer.read(name)))
|
||||
except (BadZipFile, OSError):
|
||||
continue
|
||||
if self._has_dumpstate(inner.namelist()):
|
||||
log.info("Found the bug report nested inside the archive: %s", name)
|
||||
return inner
|
||||
inner.close()
|
||||
return None
|
||||
|
||||
def init(self) -> None:
|
||||
if not self.target_path:
|
||||
if self.target_path:
|
||||
self.log.info("Checking Android bug report at path: %s", self.target_path)
|
||||
if os.path.isfile(self.target_path):
|
||||
self.from_zip(ZipFile(self.target_path))
|
||||
elif os.path.isdir(self.target_path):
|
||||
self.from_dir(self.target_path)
|
||||
self.log.warning(
|
||||
"Analysing an unpacked bugreport: file timestamps come from "
|
||||
"the extraction, not from the device. Analyse the original "
|
||||
"zip to keep the device's file timestamps."
|
||||
)
|
||||
if self.__format:
|
||||
self._resolve_device_timezone()
|
||||
|
||||
def _resolve_device_timezone(self) -> None:
|
||||
"""Name the device's timezone in module_options unless it is known already.
|
||||
|
||||
A bugreport's SYSTEM PROPERTIES section carries persist.sys.timezone.
|
||||
Zip entry times are the device's wall clock, and modules read them in
|
||||
this zone; --timezone or check-androidqf's own reading takes precedence.
|
||||
"""
|
||||
if self.module_options.get("device_timezone"):
|
||||
self.log.info("Device timezone: %s", self.module_options["device_timezone"])
|
||||
return
|
||||
|
||||
if os.path.isfile(self.target_path):
|
||||
self.from_zip(ZipFile(self.target_path))
|
||||
elif os.path.isdir(self.target_path):
|
||||
self.from_dir(self.target_path)
|
||||
probe = BugReportModule(log=self.log)
|
||||
self.module_init(probe)
|
||||
timezone = None
|
||||
try:
|
||||
dumpstate = probe._get_dumpstate_file()
|
||||
except Exception as exc:
|
||||
self.log.warning("Could not read the bugreport's dumpstate: %s", exc)
|
||||
dumpstate = None
|
||||
if dumpstate:
|
||||
properties = GetProp()
|
||||
properties.parse(
|
||||
BugReportModule.extract_command_section(
|
||||
dumpstate.decode("utf-8", errors="replace"),
|
||||
"------ SYSTEM PROPERTIES",
|
||||
)
|
||||
)
|
||||
timezone = properties.get_device_timezone()
|
||||
if timezone:
|
||||
self.log.info("Device timezone identified from the bugreport: %s", timezone)
|
||||
self.module_options["device_timezone"] = timezone
|
||||
else:
|
||||
self.log.warning(
|
||||
"persist.sys.timezone not found in the bugreport; file timestamps "
|
||||
"are the device's wall clock without a timezone. Pass --timezone "
|
||||
"to name it."
|
||||
)
|
||||
|
||||
def module_init(self, module: BugReportModule) -> None: # type: ignore[override]
|
||||
if self.__format == "zip":
|
||||
|
||||
@@ -63,6 +63,8 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
||||
if not self.target_path:
|
||||
raise ValueError("No target path specified")
|
||||
|
||||
self.log.info("Checking intrusion logs at path: %s", self.target_path)
|
||||
|
||||
if not os.path.isdir(self.target_path) and not (
|
||||
os.path.isfile(self.target_path)
|
||||
and self.target_path.lower().endswith(".zip")
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
# 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
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import AndroidQFModule
|
||||
|
||||
@@ -15,43 +11,23 @@ from .base import AndroidQFModule
|
||||
class AQFSettings(SettingsArtifact, AndroidQFModule):
|
||||
"""This module analyse setting files"""
|
||||
|
||||
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 run(self) -> None:
|
||||
for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
|
||||
namespace = setting_file[setting_file.rfind("_") + 1 : -4]
|
||||
|
||||
self.results[namespace] = {}
|
||||
data = self._get_file_content(setting_file)
|
||||
for line in data.decode("utf-8").splitlines():
|
||||
line = line.strip()
|
||||
try:
|
||||
key, value = line.split("=", 1)
|
||||
except ValueError:
|
||||
name, separator, value = line.strip().partition("=")
|
||||
if not separator:
|
||||
continue
|
||||
|
||||
try:
|
||||
self.results[namespace][key] = value
|
||||
except IndexError:
|
||||
continue
|
||||
self.results.append(
|
||||
{
|
||||
"namespace": namespace,
|
||||
"user": None,
|
||||
"name": name,
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Identified %d settings", sum([len(val) for val in self.results.values()])
|
||||
)
|
||||
self.log.info("Identified %d settings", len(self.results))
|
||||
@@ -6,12 +6,23 @@ import datetime
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
|
||||
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
|
||||
# section finishes and not necessarily between two sections.
|
||||
SECTION_DURATION = re.compile(r"^-{3,}\s*[0-9.]+s was the duration of", re.IGNORECASE)
|
||||
|
||||
# The members a bug report archive can be entered through, in the order
|
||||
# _get_dumpstate_file() tries them. An archive carrying none of them is not a
|
||||
# bug report at this level (see CmdAndroidCheckBugreport._has_dumpstate).
|
||||
DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
|
||||
|
||||
|
||||
class BugReportModule(MVTModule):
|
||||
"""This class provides a base for all Android Bug Report modules."""
|
||||
@@ -86,7 +97,9 @@ class BugReportModule(MVTModule):
|
||||
return data
|
||||
|
||||
def _get_dumpstate_file(self) -> Optional[bytes]:
|
||||
main = self._get_files_by_pattern("main_entry.txt")
|
||||
main_entry, dumpstate_log, dumpsys_txt = DUMPSTATE_ENTRY_POINTS
|
||||
|
||||
main = self._get_files_by_pattern(main_entry)
|
||||
if main:
|
||||
main_content = self._get_file_content(main[0])
|
||||
try:
|
||||
@@ -94,11 +107,11 @@ class BugReportModule(MVTModule):
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
dumpstate_logs = self._get_files_by_pattern("dumpState_*.log")
|
||||
dumpstate_logs = self._get_files_by_pattern(dumpstate_log)
|
||||
if dumpstate_logs:
|
||||
return self._get_file_content(dumpstate_logs[0])
|
||||
|
||||
dumpsys_files = self._get_files_by_pattern("*/dumpsys.txt")
|
||||
dumpsys_files = self._get_files_by_pattern(dumpsys_txt)
|
||||
if dumpsys_files:
|
||||
return self._get_file_content(dumpsys_files[0])
|
||||
|
||||
@@ -121,16 +134,41 @@ class BugReportModule(MVTModule):
|
||||
in_section = True
|
||||
continue
|
||||
if stripped.startswith("------"):
|
||||
# dumpstate prints a section's timing line when that section
|
||||
# finishes, which can land in the middle of the one being
|
||||
# written. Treating it as a boundary truncates the section at
|
||||
# an arbitrary point, silently.
|
||||
if SECTION_DURATION.match(stripped):
|
||||
continue
|
||||
break
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _device_timezone(self) -> Optional[datetime.tzinfo]:
|
||||
"""The device's timezone named in module_options, or None when unknown."""
|
||||
name = self.module_options.get("device_timezone")
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except ZoneInfoNotFoundError:
|
||||
self.log.warning("Unknown device timezone %s", name)
|
||||
return None
|
||||
|
||||
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
|
||||
"""When the file was last modified.
|
||||
|
||||
A zip entry carries the device's wall clock, so it is returned in the
|
||||
device's timezone when the bugreport names one and naive otherwise.
|
||||
An unpacked bugreport's mtime is whatever the extraction left, an
|
||||
instant returned in UTC.
|
||||
"""
|
||||
if self.zip_archive:
|
||||
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
||||
return datetime.datetime(*file_timetuple)
|
||||
else:
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
file_stat = os.stat(os.path.join(self.extract_path, file_path))
|
||||
return datetime.datetime.fromtimestamp(file_stat.st_mtime)
|
||||
return datetime.datetime(*file_timetuple, tzinfo=self._device_timezone())
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
file_stat = os.stat(os.path.join(self.extract_path, file_path))
|
||||
return datetime.datetime.fromtimestamp(
|
||||
file_stat.st_mtime, tz=datetime.timezone.utc
|
||||
)
|
||||
@@ -48,9 +48,28 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
||||
)
|
||||
self.parse(content)
|
||||
|
||||
listed = unnamed = enabled_count = 0
|
||||
for result in self.results:
|
||||
self.log.info('Found accessibility service "%s"', result.get("component"))
|
||||
if result.get("component"):
|
||||
listed += 1
|
||||
enabled = result.get("enabled")
|
||||
if enabled is True:
|
||||
enabled_count += 1
|
||||
self.log.info(
|
||||
'Found accessibility service "%s" (enabled: %s)',
|
||||
result.get("component"),
|
||||
"not stated" if enabled is None else enabled,
|
||||
)
|
||||
continue
|
||||
# The operator gets this per user as a LOW alert from
|
||||
# check_indicators(); here it only has to survive into the summary,
|
||||
# so that a stated count never reads as "a total of 0".
|
||||
unnamed += result.get("unnamed_service_count") or 0
|
||||
|
||||
self.log.info(
|
||||
"Identified a total of %d accessibility services", len(self.results)
|
||||
"Identified a total of %d accessibility services, %d reported enabled "
|
||||
"(%d more stated by the dump without a component name)",
|
||||
listed,
|
||||
enabled_count,
|
||||
unnamed,
|
||||
)
|
||||
@@ -4,15 +4,12 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from .base import BugReportModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
|
||||
from mvt.android.artifacts.getprop import GetProp
|
||||
|
||||
|
||||
class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
|
||||
@@ -41,41 +38,28 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
|
||||
def run(self) -> None:
|
||||
filesystem_files = self._get_files_by_pattern("FS/*")
|
||||
|
||||
timezone_name = None
|
||||
dumpstate = self._get_dumpstate_file()
|
||||
if dumpstate:
|
||||
section = self.extract_command_section(
|
||||
dumpstate.decode("utf-8", errors="replace"),
|
||||
"------ SYSTEM PROPERTIES",
|
||||
)
|
||||
properties = GetProp()
|
||||
properties.parse(section)
|
||||
timezone_name = properties.get_device_timezone()
|
||||
timezone = None
|
||||
if timezone_name:
|
||||
try:
|
||||
timezone = ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
self.log.warning("Unknown device timezone %s", timezone_name)
|
||||
|
||||
self.results = []
|
||||
for file in filesystem_files:
|
||||
# Only the modification time is available in the zip file metadata.
|
||||
# The timezone is the local timezone of the machine the phone.
|
||||
# A zip entry keeps the device's wall clock, read in the device's
|
||||
# timezone when the command found one (see CmdAndroidCheckBugreport);
|
||||
# an unpacked bugreport's mtime is read as a UTC instant.
|
||||
modification_time = self._get_file_modification_time(file)
|
||||
utc_time = None
|
||||
if timezone is not None:
|
||||
utc_time = convert_datetime_to_iso(
|
||||
modification_time.replace(tzinfo=timezone).astimezone(
|
||||
datetime.timezone.utc
|
||||
)
|
||||
)
|
||||
self.results.append(
|
||||
{
|
||||
"path": file,
|
||||
"modified_time": convert_datetime_to_iso(modification_time),
|
||||
"modified_time_utc": utc_time,
|
||||
"timezone": timezone_name,
|
||||
"modified_time": convert_datetime_to_iso(
|
||||
modification_time.replace(tzinfo=None)
|
||||
),
|
||||
"modified_time_utc": (
|
||||
convert_datetime_to_iso(modification_time)
|
||||
if modification_time.tzinfo
|
||||
else None
|
||||
),
|
||||
"timezone": (
|
||||
self.module_options.get("device_timezone")
|
||||
if self.zip_archive
|
||||
else "UTC"
|
||||
),
|
||||
"timestamp_source": (
|
||||
"zip_metadata" if self.zip_archive else "filesystem_metadata"
|
||||
),
|
||||
|
||||
@@ -18,5 +18,4 @@ class Settings(SettingsArtifact, BugReportModule):
|
||||
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:"
|
||||
)
|
||||
self.parse(section)
|
||||
count = sum(len(settings) for settings in self.results.values())
|
||||
self.log.info("Identified %d Android settings", count)
|
||||
self.log.info("Identified %d Android settings", len(self.results))
|
||||
@@ -56,6 +56,10 @@ class CmdCheckIOCS(Command):
|
||||
if entry not in all_modules:
|
||||
all_modules.append(entry)
|
||||
|
||||
# Read the indicators once, so that a missing indicators file is
|
||||
# reported even when no stored result matches a module.
|
||||
iocs = self.iocs
|
||||
|
||||
log.info("Checking stored results against provided indicators...")
|
||||
|
||||
total_detections = 0
|
||||
@@ -83,8 +87,8 @@ class CmdCheckIOCS(Command):
|
||||
log.warning("No result from this module, skipping it")
|
||||
continue
|
||||
|
||||
if self.iocs.total_ioc_count > 0:
|
||||
m.indicators = self.iocs
|
||||
if iocs.total_ioc_count > 0:
|
||||
m.indicators = iocs
|
||||
m.indicators.log = m.log
|
||||
|
||||
try:
|
||||
|
||||
+24
-10
@@ -8,6 +8,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from functools import cached_property
|
||||
from heapq import heappop, heappush
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -84,18 +85,18 @@ class Command:
|
||||
self.timeline: ModuleTimeline = []
|
||||
self.url_results: list[URLResult] = []
|
||||
|
||||
# Load IOCs
|
||||
self._create_storage()
|
||||
self._setup_logging()
|
||||
|
||||
if iocs is not None:
|
||||
self.iocs = iocs
|
||||
else:
|
||||
self.iocs = Indicators(self.log)
|
||||
self.iocs.load_indicators_files(self.ioc_files)
|
||||
|
||||
self.alertstore = AlertStore()
|
||||
|
||||
@cached_property
|
||||
def iocs(self) -> Indicators:
|
||||
"""Load indicators on first use. Nested commands share their parent's."""
|
||||
iocs = Indicators(self.log)
|
||||
iocs.load_indicators_files(self.ioc_files)
|
||||
return iocs
|
||||
|
||||
def _create_storage(self) -> None:
|
||||
if self.results_path and not os.path.exists(self.results_path):
|
||||
try:
|
||||
@@ -710,17 +711,30 @@ class Command:
|
||||
return ordered
|
||||
|
||||
def run(self) -> None:
|
||||
# The output folder and its command.log exist for a run, so that
|
||||
# listing modules or rejecting a target leaves nothing behind.
|
||||
# Resolving the module list can warn, so the log comes first.
|
||||
self._create_storage()
|
||||
self._setup_logging()
|
||||
|
||||
ordered_modules = self._ordered_modules()
|
||||
if ordered_modules is None:
|
||||
return
|
||||
|
||||
self._log_loaded_modules(ordered_modules)
|
||||
# Read the indicators once the run is certain to happen, before
|
||||
# init() does any work on the target, so that a missing indicators
|
||||
# file is reported first and every module gets the same object.
|
||||
iocs = self.iocs
|
||||
|
||||
# Commands announce their target from init(), so it goes before the
|
||||
# module list.
|
||||
try:
|
||||
self.init()
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
self._log_loaded_modules(ordered_modules)
|
||||
|
||||
executed_by_type: dict[type[MVTModule], MVTModule] = {}
|
||||
for module in ordered_modules:
|
||||
|
||||
@@ -740,8 +754,8 @@ class Command:
|
||||
for dependency, resolved in self._module_dependencies(module)
|
||||
}
|
||||
|
||||
if self.iocs.total_ioc_count:
|
||||
m.indicators = self.iocs
|
||||
if iocs.total_ioc_count:
|
||||
m.indicators = iocs
|
||||
m.indicators.log = m.log
|
||||
|
||||
if self.serial:
|
||||
|
||||
@@ -12,7 +12,9 @@ from pydantic_settings import (
|
||||
YamlConfigSettingsSource,
|
||||
)
|
||||
|
||||
MVT_CONFIG_FOLDER = user_config_dir("mvt")
|
||||
# MVT_CONFIG_FOLDER in the environment relocates the settings file, so that
|
||||
# a test run or a scripted install never touches the user's own.
|
||||
MVT_CONFIG_FOLDER = os.environ.get("MVT_CONFIG_FOLDER") or user_config_dir("mvt")
|
||||
MVT_CONFIG_PATH = os.path.join(MVT_CONFIG_FOLDER, "config.yaml")
|
||||
|
||||
|
||||
@@ -59,13 +61,16 @@ class MVTSettings(BaseSettings):
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> Tuple[PydanticBaseSettingsSource, ...]:
|
||||
yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH)
|
||||
sources: Tuple[PydanticBaseSettingsSource, ...] = (
|
||||
yaml_source,
|
||||
YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH),
|
||||
init_settings,
|
||||
)
|
||||
# Always load env variables by default
|
||||
sources = (env_settings,) + sources
|
||||
# Load env variables only when asked to. initialise() constructs the
|
||||
# settings once without them so that what gets written back to
|
||||
# config.yaml never includes values taken from the environment.
|
||||
# init_settings() returns the keyword arguments passed to the constructor.
|
||||
if init_settings().get("load_env", True):
|
||||
sources = (env_settings,) + sources
|
||||
return sources
|
||||
|
||||
def save_settings(
|
||||
@@ -92,7 +97,7 @@ class MVTSettings(BaseSettings):
|
||||
|
||||
Afterwards we load the settings again, this time including the env variables.
|
||||
"""
|
||||
# Set invalid env prefix to avoid loading env variables.
|
||||
# Construct the settings without env variables so they are not persisted.
|
||||
settings = cls(load_env=False)
|
||||
settings.save_settings()
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ from appdirs import user_data_dir
|
||||
from .config import settings
|
||||
from .url import URL
|
||||
|
||||
MVT_DATA_FOLDER = user_data_dir("mvt")
|
||||
# MVT_DATA_FOLDER in the environment relocates the downloaded indicators and
|
||||
# the update-check state kept next to them.
|
||||
MVT_DATA_FOLDER = os.environ.get("MVT_DATA_FOLDER") or user_data_dir("mvt")
|
||||
MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,7 +73,9 @@ class Indicators:
|
||||
if os.path.isfile(path) and path.lower().endswith(".stix2"):
|
||||
self.parse_stix2(path)
|
||||
elif os.path.isdir(path):
|
||||
for file in glob.glob(os.path.join(path, "**", "*.stix2"), recursive=True):
|
||||
for file in glob.glob(
|
||||
os.path.join(path, "**", "*.stix2"), recursive=True
|
||||
):
|
||||
self.parse_stix2(file)
|
||||
else:
|
||||
self.log.error(
|
||||
@@ -518,9 +522,7 @@ class Indicators:
|
||||
the original URL order.
|
||||
"""
|
||||
batches = [list(urls) if urls else [] for urls in url_batches]
|
||||
unique_urls = list(
|
||||
dict.fromkeys(url for urls in batches for url in urls)
|
||||
)
|
||||
unique_urls = list(dict.fromkeys(url for urls in batches for url in urls))
|
||||
|
||||
if not unique_urls:
|
||||
return [None] * len(batches)
|
||||
|
||||
@@ -349,7 +349,9 @@ class URL:
|
||||
return tld_obj
|
||||
if tld_obj is None:
|
||||
return ""
|
||||
return tld_obj.parsed_url.netloc.lower().lstrip("www.")
|
||||
# removeprefix, not lstrip: lstrip takes a set of characters, so it ate the
|
||||
# leading "w"s and dots of any domain ("web.evil.com" -> "eb.evil.com").
|
||||
return tld_obj.parsed_url.netloc.lower().removeprefix("www.")
|
||||
|
||||
def get_top_level(self) -> str:
|
||||
"""Get only the top-level domain from a URL.
|
||||
|
||||
+8
-10
@@ -318,8 +318,6 @@ def check_backup(
|
||||
if not cmd.resolve_backup_path():
|
||||
ctx.exit(1)
|
||||
|
||||
log.info("Checking iTunes backup located at: %s", cmd.target_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
@@ -386,8 +384,6 @@ def check_fs(
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking iOS filesystem located at: %s", dump_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
@@ -449,18 +445,20 @@ 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:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path)
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
@@ -59,6 +59,9 @@ class CmdIOSCheckBackup(Command):
|
||||
self.name = "check-backup"
|
||||
self.modules = BACKUP_MODULES + MIXED_MODULES
|
||||
|
||||
def init(self) -> None:
|
||||
self.log.info("Checking iTunes backup located at: %s", self.target_path)
|
||||
|
||||
def resolve_backup_path(self) -> bool:
|
||||
target_path = getattr(self, "target_path", None)
|
||||
if not isinstance(target_path, str) or not target_path:
|
||||
|
||||
@@ -51,5 +51,8 @@ class CmdIOSCheckFS(Command):
|
||||
self.name = "check-fs"
|
||||
self.modules = FS_MODULES + MIXED_MODULES
|
||||
|
||||
def init(self) -> None:
|
||||
self.log.info("Checking iOS filesystem located at: %s", self.target_path)
|
||||
|
||||
def module_init(self, module):
|
||||
module.is_fs_dump = True
|
||||
@@ -7,7 +7,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import zlib
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Optional
|
||||
@@ -16,6 +18,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 +56,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] = []
|
||||
@@ -76,11 +81,15 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
if not self.target_path:
|
||||
raise ValueError("A sysdiagnose path is required")
|
||||
|
||||
self.log.info("Checking iOS sysdiagnose at path: %s", self.target_path)
|
||||
|
||||
if os.path.isdir(self.target_path):
|
||||
self.sysdiagnose_format = "dir"
|
||||
parent_path = Path(self.target_path).absolute().parent
|
||||
for root, _, filenames in os.walk(self.target_path):
|
||||
for filename in filenames:
|
||||
if filename.startswith("._"):
|
||||
continue
|
||||
absolute_path = os.path.join(root, filename)
|
||||
file_path = os.path.relpath(absolute_path, parent_path)
|
||||
self.sysdiagnose_files.append(file_path)
|
||||
@@ -94,8 +103,19 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
|
||||
self.log.info("Parsing sysdiagnose archive. This might take a while...")
|
||||
self.sysdiagnose_format = "tar"
|
||||
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
|
||||
self._extract_sysdiagnose_archive()
|
||||
try:
|
||||
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
|
||||
self._extract_sysdiagnose_archive()
|
||||
except (tarfile.ReadError, EOFError, zlib.error, OSError) as exc:
|
||||
# A truncated archive ends in EOFError from gzip, which Click would
|
||||
# otherwise report as a bare "Aborted!" with no reason.
|
||||
self.log.critical(
|
||||
"Unable to read the sysdiagnose archive %s: %s. "
|
||||
"The file may be truncated or not a gzip-compressed tarball.",
|
||||
self.target_path,
|
||||
exc,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def _extract_sysdiagnose_archive(self) -> None:
|
||||
archive = self.sysdiagnose_archive
|
||||
@@ -119,6 +139,11 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
|
||||
if not member_path.parts:
|
||||
continue
|
||||
# AppleDouble sidecars (._name) carry a file's extended attributes,
|
||||
# not sysdiagnose content. Device archives hold hundreds of them;
|
||||
# bsdtar hides them from listings, tarfile does not.
|
||||
if member_path.name.startswith("._"):
|
||||
continue
|
||||
archive_roots.add(member_path.parts[0])
|
||||
|
||||
if member.isdir():
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1271,5 +1271,13 @@
|
||||
{
|
||||
"version": "26.6.1",
|
||||
"build": "23G83"
|
||||
},
|
||||
{
|
||||
"version": "26.6.2",
|
||||
"build": "23G90"
|
||||
},
|
||||
{
|
||||
"version": "27.0",
|
||||
"build": "24A437"
|
||||
}
|
||||
]
|
||||
@@ -52,7 +52,7 @@ class MVTEncryptedBackup(EncryptedBackup):
|
||||
|
||||
NOTE: This subclass relies on internal APIs of iphone_backup_decrypt
|
||||
(specifically _read_and_unlock_keybag, _keybag, and the Keybag class
|
||||
internals). Pinned to iphone_backup_decrypt==0.9.0.
|
||||
internals). Pinned to iphone_backup_decrypt==0.10.0.
|
||||
"""
|
||||
|
||||
def __init__(self, *, backup_directory, passphrase=None, derived_key=None):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -35,11 +35,33 @@ class TestDumpsysAccessibilityArtifact:
|
||||
|
||||
assert len(da.results) == 0
|
||||
da.parse(data)
|
||||
assert len(da.results) == 1
|
||||
# One named service, plus one count-only record: the dump states
|
||||
# `installedServiceCount=2` and names only one component.
|
||||
assert len(da.results) == 2
|
||||
assert da.results[0]["package_name"] == "com.malware.accessibility"
|
||||
assert da.results[0]["service_name"] == "com.malware.service.malwareservice"
|
||||
assert da.results[0]["enabled"] is True
|
||||
assert da.results[0]["installed"] is False
|
||||
# This fixture never prints an `installed services:` section, so the
|
||||
# dump does not state the installed status. Reporting False would turn
|
||||
# "not stated" into "not installed", so it reads None here.
|
||||
assert da.results[0]["installed"] is None
|
||||
|
||||
def test_parsing_installed_and_enabled(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
with open(get_artifact("android_data/dumpsys_accessibility_enabled.txt")) as f:
|
||||
da.parse(f.read())
|
||||
|
||||
assert len(da.results) == 5
|
||||
assert all(result["component"] for result in da.results)
|
||||
assert all(result["installed"] is True for result in da.results)
|
||||
enabled = [result for result in da.results if result["enabled"] is True]
|
||||
assert len(enabled) == 1
|
||||
assert enabled[0]["package_name"] == "com.samsung.accessibility"
|
||||
assert (
|
||||
enabled[0]["component"]
|
||||
== "com.samsung.accessibility/.universalswitch.UniversalSwitchService"
|
||||
)
|
||||
assert sum(result["enabled"] is False for result in da.results) == 4
|
||||
|
||||
def test_accessibility_service_alert(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
@@ -50,9 +72,11 @@ class TestDumpsysAccessibilityArtifact:
|
||||
|
||||
da.check_indicators()
|
||||
|
||||
assert len(da.alertstore.alerts) == 1
|
||||
assert len(da.alertstore.alerts) == 2
|
||||
assert da.alertstore.alerts[0].level == AlertLevel.MEDIUM
|
||||
assert da.alertstore.alerts[0].event == da.results[0]
|
||||
assert da.alertstore.alerts[1].level == AlertLevel.LOW
|
||||
assert da.alertstore.alerts[1].event == da.results[1]
|
||||
|
||||
def test_same_component_is_kept_for_each_user(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
@@ -84,7 +108,11 @@ User state[attributes:{id=10
|
||||
assert len(da.alertstore.alerts) == 0
|
||||
da.check_indicators()
|
||||
assert len(da.alertstore.alerts) == len(da.results)
|
||||
assert da.alertstore.count(AlertLevel.MEDIUM) == 3
|
||||
# Every service in this fixture is installed and switched off
|
||||
# (`enabled services:{}` is printed and empty), so the three non-IOC
|
||||
# findings are LOW, not MEDIUM. The IOC match is unaffected by the
|
||||
# state.
|
||||
assert da.alertstore.count(AlertLevel.LOW) == 3
|
||||
assert da.alertstore.count(AlertLevel.CRITICAL) == 1
|
||||
critical_alert = next(
|
||||
alert
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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/
|
||||
"""The dump's own installed-service count must survive into the artifact.
|
||||
|
||||
Most builds never print the `installed services: {…}` block; they state `installedServiceCount=N` in the user's `attributes:{…}` line and
|
||||
list nothing. Dropping that number makes an artifact that says "no
|
||||
accessibility services" about a dump that said there are five.
|
||||
"""
|
||||
|
||||
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
|
||||
from mvt.common.alerts import AlertLevel
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
AOSP_NO_LIST = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[
|
||||
attributes:{id=0, touchExplorationEnabled=false, installedServiceCount=5}
|
||||
Bound services:{}
|
||||
Enabled services:{}
|
||||
Binding services:{}
|
||||
Crashed services:{}
|
||||
"""
|
||||
|
||||
ONE_UI_WITH_LIST = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, installedServiceCount=2}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
1 : com.other.app/.Helper
|
||||
}
|
||||
enabled services: {
|
||||
}
|
||||
"""
|
||||
|
||||
TWO_USERS = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, installedServiceCount=1}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
}
|
||||
User state[attributes:{id=95, installedServiceCount=3}
|
||||
Enabled services:{}
|
||||
"""
|
||||
|
||||
PARTIAL_TWO_USERS = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, installedServiceCount=3}
|
||||
Enabled services:{{com.example.app/com.example.app.Service}}
|
||||
User state[attributes:{id=10, installedServiceCount=1}
|
||||
Enabled services:{{com.other.app/.Helper}}
|
||||
"""
|
||||
|
||||
ZERO_COUNT = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, installedServiceCount=0}
|
||||
Enabled services:{}
|
||||
"""
|
||||
|
||||
|
||||
def _parse(content):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(content)
|
||||
return artifact
|
||||
|
||||
|
||||
class TestAccessibilityInstalledServiceCount:
|
||||
def test_stated_count_without_a_list_is_kept(self):
|
||||
artifact = _parse(AOSP_NO_LIST)
|
||||
assert len(artifact.results) == 1
|
||||
record = artifact.results[0]
|
||||
assert record["installed_service_count"] == 5
|
||||
assert record["component"] is None
|
||||
# Every state flag stays unknown: the dump named no service to which a
|
||||
# state could belong.
|
||||
assert record["installed"] is None
|
||||
assert record["enabled"] is None
|
||||
|
||||
def test_a_stated_count_is_reported_as_low(self):
|
||||
artifact = _parse(AOSP_NO_LIST)
|
||||
artifact.check_indicators()
|
||||
alerts = artifact.alertstore.alerts
|
||||
assert len(alerts) == 1
|
||||
# A count is a coverage statement, not a running service: it must not
|
||||
# compete with a service the dump says is enabled.
|
||||
assert alerts[0].level == AlertLevel.LOW
|
||||
assert "does not list their component names" in alerts[0].message
|
||||
assert "5 installed" in alerts[0].message
|
||||
|
||||
def test_a_listed_user_carries_the_count_on_each_service(self):
|
||||
artifact = _parse(ONE_UI_WITH_LIST)
|
||||
assert len(artifact.results) == 2
|
||||
assert {record["installed_service_count"] for record in artifact.results} == {2}
|
||||
assert all(record["component"] for record in artifact.results)
|
||||
|
||||
def test_only_the_unlisted_user_gets_a_count_record(self):
|
||||
artifact = _parse(TWO_USERS)
|
||||
listed = [record for record in artifact.results if record["component"]]
|
||||
unlisted = [record for record in artifact.results if not record["component"]]
|
||||
assert [record["user_id"] for record in listed] == [0]
|
||||
assert [record["user_id"] for record in unlisted] == [95]
|
||||
assert unlisted[0]["installed_service_count"] == 3
|
||||
|
||||
def test_a_zero_count_adds_nothing(self):
|
||||
# "Zero installed" is a negative result the empty section already
|
||||
# states; a record for it would be noise.
|
||||
assert _parse(ZERO_COUNT).results == []
|
||||
|
||||
def test_a_partly_named_count_reports_the_unnamed_rest(self):
|
||||
# The Android 14 fixture states `installedServiceCount=2` and names one
|
||||
# enabled component. The listing is incomplete, and must not read as
|
||||
# complete.
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
with open(
|
||||
get_artifact("android_data/dumpsys_accessibility_v14_or_later.txt")
|
||||
) as handle:
|
||||
artifact.parse(handle.read())
|
||||
unlisted = [record for record in artifact.results if not record["component"]]
|
||||
assert len(unlisted) == 1
|
||||
assert unlisted[0]["installed_service_count"] == 2
|
||||
assert unlisted[0]["unnamed_service_count"] == 1
|
||||
|
||||
artifact.check_indicators()
|
||||
low = [
|
||||
alert
|
||||
for alert in artifact.alertstore.alerts
|
||||
if alert.level == AlertLevel.LOW
|
||||
]
|
||||
assert len(low) == 1
|
||||
assert "only 1 of them (1 unnamed)" in low[0].message
|
||||
|
||||
def test_the_unnamed_rest_is_counted_per_user(self):
|
||||
# User 0 names one of three, user 10 names its only one: the gap
|
||||
# belongs to user 0 alone.
|
||||
artifact = _parse(PARTIAL_TWO_USERS)
|
||||
unlisted = [record for record in artifact.results if not record["component"]]
|
||||
assert [
|
||||
(record["user_id"], record["unnamed_service_count"]) for record in unlisted
|
||||
] == [(0, 2)]
|
||||
|
||||
def test_a_fully_named_count_adds_nothing(self):
|
||||
artifact = _parse(ONE_UI_WITH_LIST)
|
||||
assert all(
|
||||
record["unnamed_service_count"] is None for record in artifact.results
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
# 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/
|
||||
"""Installed is not the same as enabled, and the dump says which.
|
||||
|
||||
Two things this guards:
|
||||
|
||||
* a section the dump never printed must read as None ("not stated"), not as
|
||||
False ("not enabled");
|
||||
* the alert must name the state, instead of firing identically on a device
|
||||
where nothing is switched on and one where something is bound.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
|
||||
from mvt.common.alerts import AlertLevel
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
NO_STATE_SECTIONS = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, currentUser=true}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
}
|
||||
"""
|
||||
|
||||
ENABLED_BLOCK = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, currentUser=true}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
1 : com.other.app/.Helper
|
||||
}
|
||||
enabled services: {
|
||||
0 : com.other.app/.Helper
|
||||
}
|
||||
bound services:{
|
||||
0 : com.other.app/.Helper
|
||||
}
|
||||
"""
|
||||
|
||||
# User 0 prints only `installed services`, user 10 only `Enabled services`.
|
||||
# Neither section speaks for the other user.
|
||||
TWO_USERS_DIFFERENT_SECTIONS = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, currentUser=true}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
}
|
||||
User state[attributes:{id=10, currentUser=false}
|
||||
Enabled services:{{com.other.app/.Helper}}
|
||||
"""
|
||||
|
||||
|
||||
class _IndicatorsMatching:
|
||||
"""Minimal stand-in: matches one package id, like the STIX2 loader would."""
|
||||
|
||||
def __init__(self, package_name: str) -> None:
|
||||
self.package_name = package_name
|
||||
|
||||
def check_app_id(self, app_id):
|
||||
if app_id != self.package_name:
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
message=f"Found a known suspicious app: {app_id}", ioc={"value": app_id}
|
||||
)
|
||||
|
||||
|
||||
class TestAccessibilityServiceState:
|
||||
def _parse(self, content):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(content)
|
||||
return {r["component"]: r for r in artifact.results}
|
||||
|
||||
def test_absent_sections_leave_the_state_unknown(self):
|
||||
# None, not False: a build that does not print the sections says
|
||||
# nothing about what is enabled, and that must not read as "nothing".
|
||||
state = self._parse(NO_STATE_SECTIONS)[
|
||||
"com.example.app/com.example.app.Service"
|
||||
]
|
||||
assert state["installed"] is True
|
||||
assert state["enabled"] is None
|
||||
assert state["bound"] is None
|
||||
|
||||
def test_enabled_and_bound_are_attributed_per_service(self):
|
||||
results = self._parse(ENABLED_BLOCK)
|
||||
installed_only = results["com.example.app/com.example.app.Service"]
|
||||
active = results["com.other.app/.Helper"]
|
||||
assert (installed_only["enabled"], installed_only["bound"]) == (False, False)
|
||||
assert (active["enabled"], active["bound"]) == (True, True)
|
||||
|
||||
def test_alert_message_carries_the_state(self):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
with open(get_artifact("android_data/dumpsys_accessibility.txt")) as handle:
|
||||
artifact.parse(handle.read())
|
||||
artifact.check_indicators()
|
||||
assert artifact.alertstore.alerts
|
||||
assert all("installed" in alert.message for alert in artifact.alertstore.alerts)
|
||||
|
||||
def test_a_switched_off_service_is_low_and_a_running_one_medium(self):
|
||||
# A service the dump says is OFF still reaches the analyst, but must not
|
||||
# compete with one that is actually bound. "Not stated" is not "off".
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(ENABLED_BLOCK)
|
||||
artifact.check_indicators()
|
||||
by_level = {}
|
||||
for alert in artifact.alertstore.alerts:
|
||||
by_level.setdefault(alert.level, []).append(alert.message)
|
||||
assert artifact.alertstore.count(AlertLevel.LOW) == 1
|
||||
assert artifact.alertstore.count(AlertLevel.MEDIUM) == 1
|
||||
assert "com.example.app" in by_level[AlertLevel.LOW][0]
|
||||
assert "com.other.app" in by_level[AlertLevel.MEDIUM][0]
|
||||
|
||||
def test_an_unstated_enabled_state_stays_medium(self):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(NO_STATE_SECTIONS)
|
||||
artifact.check_indicators()
|
||||
assert artifact.alertstore.count(AlertLevel.MEDIUM) == 1
|
||||
assert artifact.alertstore.count(AlertLevel.LOW) == 0
|
||||
|
||||
def test_a_disabled_service_is_still_matched_against_indicators(self):
|
||||
# The state decides the severity of an ordinary finding, never whether
|
||||
# the package is compared with the IOC feeds.
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(ENABLED_BLOCK)
|
||||
artifact.indicators = _IndicatorsMatching("com.example.app")
|
||||
artifact.check_indicators()
|
||||
assert artifact.alertstore.count(AlertLevel.CRITICAL) == 1
|
||||
assert artifact.alertstore.count(AlertLevel.LOW) == 0
|
||||
|
||||
def test_printed_sections_are_tracked_per_user(self):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(TWO_USERS_DIFFERENT_SECTIONS)
|
||||
by_user = {r["user_id"]: r for r in artifact.results}
|
||||
# User 0's enabled state is not stated, so it is unknown, not off.
|
||||
assert (by_user[0]["installed"], by_user[0]["enabled"]) == (True, None)
|
||||
# User 10's installed state is not stated either.
|
||||
assert (by_user[10]["installed"], by_user[10]["enabled"]) == (None, True)
|
||||
|
||||
artifact.check_indicators()
|
||||
assert artifact.alertstore.count(AlertLevel.LOW) == 0
|
||||
assert artifact.alertstore.count(AlertLevel.MEDIUM) == 2
|
||||
assert not any(
|
||||
"installed, not enabled" in alert.message
|
||||
for alert in artifact.alertstore.alerts
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -144,3 +144,18 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
"Detected uninstall of package com.example.app (vers 0)"
|
||||
)
|
||||
assert uninstall_alert.event_time == "2026-01-10"
|
||||
|
||||
def test_malformed_update_line_does_not_lose_later_records(self):
|
||||
# A truncated "Update" line, or one from a vendor that omits "vers=",
|
||||
# used to raise out of parse() and lose every record after it.
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
" Daily from 2021-05-10-08-00-00 to 2021-05-11-08-00-00:\n"
|
||||
" Update com.first vers=1\n"
|
||||
" Update com.truncated\n"
|
||||
" Update com.no.equals vers 2\n"
|
||||
" Update com.last vers=3\n"
|
||||
)
|
||||
|
||||
assert [r["package_name"] for r in dba.results] == ["com.first", "com.last"]
|
||||
assert [r["version_code"] for r in dba.results] == [1, 3]
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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/
|
||||
"""A mount is writable only if both mountinfo option layers allow it."""
|
||||
|
||||
from mvt.android.artifacts.mounts import Mounts
|
||||
from mvt.common.alerts import AlertLevel
|
||||
|
||||
# fields[5] is the per-mount (VFS) layer, the field after "-" the superblock.
|
||||
RO_OVERLAY = (
|
||||
"304 166 0:130 / /product/usr rw,relatime shared:63 - overlay overlay "
|
||||
"ro,seclabel,lowerdir=/mnt/vendor/ext/product/usr:/product/usr"
|
||||
)
|
||||
RW_SYSTEM = (
|
||||
"305 166 0:131 / /system rw,relatime shared:64 - ext4 /dev/block/dm-1 "
|
||||
"rw,seclabel,errors=panic"
|
||||
)
|
||||
|
||||
|
||||
def _run(line):
|
||||
mounts = Mounts()
|
||||
mounts.results = Mounts.parse_mountinfo(line, 1)
|
||||
mounts.check_indicators()
|
||||
return mounts
|
||||
|
||||
|
||||
class TestMountsReadWriteLayers:
|
||||
def test_rw_vfs_over_ro_superblock_is_not_writable(self):
|
||||
mounts = _run(RO_OVERLAY)
|
||||
assert mounts.results[0]["is_read_write"] is False
|
||||
assert mounts.alertstore.count(AlertLevel.HIGH) == 0
|
||||
assert mounts.alertstore.count(AlertLevel.MEDIUM) == 0
|
||||
|
||||
def test_rw_in_both_layers_still_alerts(self):
|
||||
mounts = _run(RW_SYSTEM)
|
||||
assert mounts.results[0]["is_read_write"] is True
|
||||
assert mounts.alertstore.count(AlertLevel.HIGH) == 1
|
||||
|
||||
def test_both_option_layers_are_still_reported(self):
|
||||
mounts = _run(RO_OVERLAY)
|
||||
options = mounts.results[0]["options_list"]
|
||||
assert "rw" in options and "ro" in options
|
||||
@@ -0,0 +1,170 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.android.artifacts.settings import Settings
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
|
||||
def parse_bugreport_settings() -> Settings:
|
||||
settings = Settings()
|
||||
with open(get_artifact("android_data/bugreport/dumpstate.txt")) as handle:
|
||||
data = handle.read()
|
||||
|
||||
settings.parse(settings.extract_dumpsys_section(data, "DUMP OF SERVICE settings:"))
|
||||
return settings
|
||||
|
||||
|
||||
def find(settings: Settings, name: str) -> list:
|
||||
return [result for result in settings.results if result["name"] == name]
|
||||
|
||||
|
||||
class TestSettingsArtifact:
|
||||
def test_parsing(self):
|
||||
settings = parse_bugreport_settings()
|
||||
|
||||
assert len(settings.results) == 12
|
||||
assert {result["namespace"] for result in settings.results} == {
|
||||
"config",
|
||||
"global",
|
||||
"secure",
|
||||
}
|
||||
assert settings.results[0] == {
|
||||
"namespace": "config",
|
||||
"user": "0",
|
||||
"_id": "682",
|
||||
"name": "namespace_one/blocked_components",
|
||||
"pkg": "com.example.services",
|
||||
"value": (
|
||||
"com.android.settings,com.android.vending,\n"
|
||||
"com.example.dialer,\n"
|
||||
"com.example.camera"
|
||||
),
|
||||
"default": (
|
||||
"com.android.settings,\n"
|
||||
" com.android.vending,\n"
|
||||
" com.example.dialer"
|
||||
),
|
||||
"defaultSystemSet": "false",
|
||||
"history": [],
|
||||
}
|
||||
|
||||
def test_multiline_values_are_kept_whole(self):
|
||||
settings = parse_bugreport_settings()
|
||||
|
||||
assert find(settings, "namespace_one/allowed_packages")[0]["value"] == (
|
||||
"com.example.messaging,\ncom.example.chat"
|
||||
)
|
||||
assert find(settings, "widget_instance_data")[0]["value"] == (
|
||||
'{\n "version": 1,\n "data": [\n {\n "number": 10000,\n'
|
||||
' "package_name": "com.example.widget"\n }\n ]\n}'
|
||||
)
|
||||
|
||||
def test_trailing_default_is_not_part_of_the_value(self):
|
||||
settings = parse_bugreport_settings()
|
||||
|
||||
record = find(settings, "namespace_one/streaming_blocked_components")[0]
|
||||
assert record["value"] == "com.example.dialer,com.example.camera"
|
||||
assert record["default"] == "com.android.settings,\n com.android.vending"
|
||||
|
||||
def test_trailing_metadata_is_not_part_of_the_value(self):
|
||||
settings = parse_bugreport_settings()
|
||||
|
||||
record = find(settings, "lock_screen_show_notifications")[0]
|
||||
assert record["value"] == "1"
|
||||
assert record["defaultSystemSet"] == "true"
|
||||
assert record["isValuePreservedInRestore"] == "true"
|
||||
|
||||
# Without a default, the tag or the restore token follows the value.
|
||||
record = find(settings, "accessibility_enabled")[1]
|
||||
assert record["value"] == "0"
|
||||
assert record["tag"] == "null"
|
||||
assert "default" not in record
|
||||
|
||||
record = find(settings, "send_action_app_error")[0]
|
||||
assert record["value"] == "1"
|
||||
assert record["isValuePreservedInRestore"] == "false"
|
||||
|
||||
def test_dumps_after_the_last_block_are_not_part_of_the_last_row(self):
|
||||
settings = parse_bugreport_settings()
|
||||
|
||||
assert settings.results[-1] == {
|
||||
"namespace": "secure",
|
||||
"user": "10",
|
||||
"_id": "311",
|
||||
"name": "accessibility_enabled",
|
||||
"pkg": "android",
|
||||
"value": "0",
|
||||
"tag": "null",
|
||||
"history": [],
|
||||
}
|
||||
|
||||
def test_repeated_names_are_kept_as_separate_records(self):
|
||||
settings = parse_bugreport_settings()
|
||||
|
||||
widgets = find(settings, "widget_instance_data")
|
||||
assert [record["_id"] for record in widgets] == ["771", "41654"]
|
||||
|
||||
accessibility = find(settings, "accessibility_enabled")
|
||||
assert [(record["user"], record["value"]) for record in accessibility] == [
|
||||
("0", "1"),
|
||||
("10", "0"),
|
||||
]
|
||||
|
||||
def test_setting_without_recording_package(self):
|
||||
settings = parse_bugreport_settings()
|
||||
|
||||
record = find(settings, "hidden_api_blacklist_exemptions")[0]
|
||||
assert "pkg" not in record
|
||||
assert record["value"] == "{null}"
|
||||
|
||||
def test_history_timestamps_resolved_against_section_end(self):
|
||||
settings = parse_bugreport_settings()
|
||||
|
||||
# The section was dumped on 2022-03-29, so an 11-02 entry belongs to
|
||||
# the previous year and an 03-14 entry to the same year.
|
||||
assert find(settings, "development_settings_enabled")[0]["history"] == [
|
||||
{
|
||||
"timestamp": "2021-11-02 11:21:22.212000",
|
||||
"oldValue": "null",
|
||||
"newValue": "1",
|
||||
"pkg": "com.android.settings",
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-03-14 09:02:11.100000",
|
||||
"oldValue": "1",
|
||||
"newValue": "0",
|
||||
"pkg": "com.example.updater",
|
||||
},
|
||||
]
|
||||
|
||||
def test_history_without_a_section_end_has_no_timestamp(self):
|
||||
settings = Settings()
|
||||
settings.parse(
|
||||
"SECURE SETTINGS (user 0)\n"
|
||||
"_id:240 name:accessibility_enabled pkg:android value:1\n"
|
||||
"\tHistory (accessibility_enabled)\n"
|
||||
"\t\ttime:03-28 22:41:07.980 mode:update oldValue:0 newValue:1 "
|
||||
"package:com.example.helper\n"
|
||||
)
|
||||
|
||||
assert settings.results[0]["history"] == [
|
||||
{
|
||||
"timestamp": None,
|
||||
"oldValue": "0",
|
||||
"newValue": "1",
|
||||
"pkg": "com.example.helper",
|
||||
}
|
||||
]
|
||||
|
||||
def test_dangerous_setting_is_detected_with_the_changing_package(self):
|
||||
settings = parse_bugreport_settings()
|
||||
settings.check_indicators()
|
||||
|
||||
assert len(settings.alertstore.alerts) == 1
|
||||
alert = settings.alertstore.alerts[0]
|
||||
assert "accessibility_enabled = 1" in alert.message
|
||||
assert alert.event_time == "2022-03-28 22:41:07.980000"
|
||||
assert alert.event["history"][0]["pkg"] == "com.example.helper"
|
||||
@@ -0,0 +1,73 @@
|
||||
# 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 logging
|
||||
|
||||
from mvt.android.modules.bugreport.dumpsys_accessibility import DumpsysAccessibility
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
|
||||
def _run(content, monkeypatch, caplog):
|
||||
module = DumpsysAccessibility()
|
||||
dumpstate = f"DUMP OF SERVICE accessibility:\n{content}".encode()
|
||||
monkeypatch.setattr(module, "_get_dumpstate_file", lambda: dumpstate)
|
||||
with caplog.at_level(logging.INFO):
|
||||
module.run()
|
||||
|
||||
|
||||
def test_logs_mixed_enabled_states(monkeypatch, caplog):
|
||||
with open(get_artifact("android_data/dumpsys_accessibility_enabled.txt")) as handle:
|
||||
_run(handle.read(), monkeypatch, caplog)
|
||||
|
||||
assert (
|
||||
'Found accessibility service "com.samsung.accessibility/'
|
||||
'.universalswitch.UniversalSwitchService" (enabled: True)' in caplog.messages
|
||||
)
|
||||
assert sum("(enabled: False)" in message for message in caplog.messages) == 4
|
||||
assert (
|
||||
"Identified a total of 5 accessibility services, 1 reported enabled "
|
||||
"(0 more stated by the dump without a component name)" in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
def test_logs_enabled_count_alongside_unnamed_count(monkeypatch, caplog):
|
||||
with open(
|
||||
get_artifact("android_data/dumpsys_accessibility_v14_or_later.txt")
|
||||
) as handle:
|
||||
_run(handle.read(), monkeypatch, caplog)
|
||||
|
||||
assert (
|
||||
"Identified a total of 1 accessibility services, 1 reported enabled "
|
||||
"(1 more stated by the dump without a component name)" in caplog.messages
|
||||
)
|
||||
assert (
|
||||
sum("Found accessibility service" in message for message in caplog.messages)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_logs_unknown_enabled_state_without_treating_it_as_disabled(
|
||||
monkeypatch, caplog
|
||||
):
|
||||
_run(
|
||||
"""User state[attributes:{id=0, installedServiceCount=2}
|
||||
installed services: {
|
||||
0 : com.example/.Service
|
||||
}
|
||||
""",
|
||||
monkeypatch,
|
||||
caplog,
|
||||
)
|
||||
|
||||
assert (
|
||||
'Found accessibility service "com.example/.Service" (enabled: not stated)'
|
||||
in caplog.messages
|
||||
)
|
||||
assert not any("(enabled: False)" in message for message in caplog.messages)
|
||||
assert (
|
||||
"Identified a total of 1 accessibility services, 0 reported enabled "
|
||||
"(1 more stated by the dump without a component name)" in caplog.messages
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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/
|
||||
"""A section ends at the next section, not at a timing line printed inside it."""
|
||||
|
||||
from mvt.android.modules.bugreport.base import BugReportModule
|
||||
|
||||
# dumpstate prints a section's duration when that section finishes, which can
|
||||
# land in the middle of the section currently being written.
|
||||
DUMPSTATE = """\
|
||||
------ SYSTEM PROPERTIES (getprop) ------
|
||||
[nfc.initialized]: [true]
|
||||
------ 0.101s was the duration of 'DROPBOX SYSTEM SERVER CRASHES' ------
|
||||
[ro.build.version.sdk]: [30]
|
||||
[ro.product.model]: [SM-A305F]
|
||||
------ 0.064s was the duration of 'SYSTEM PROPERTIES' ------
|
||||
------ STORAGE INFO (df) ------
|
||||
/dev/root 2.9G
|
||||
"""
|
||||
|
||||
|
||||
class TestExtractCommandSection:
|
||||
def test_a_foreign_timing_line_does_not_end_the_section(self):
|
||||
section = BugReportModule.extract_command_section(
|
||||
DUMPSTATE, "------ SYSTEM PROPERTIES"
|
||||
)
|
||||
assert "[ro.product.model]: [SM-A305F]" in section
|
||||
assert section.count("\n") == 2
|
||||
|
||||
def test_the_next_section_is_still_the_boundary(self):
|
||||
section = BugReportModule.extract_command_section(
|
||||
DUMPSTATE, "------ SYSTEM PROPERTIES"
|
||||
)
|
||||
assert "STORAGE INFO" not in section
|
||||
assert "/dev/root" not in section
|
||||
|
||||
def test_timing_lines_are_not_returned_as_content(self):
|
||||
section = BugReportModule.extract_command_section(
|
||||
DUMPSTATE, "------ SYSTEM PROPERTIES"
|
||||
)
|
||||
assert "was the duration of" not in section
|
||||
@@ -0,0 +1,56 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
"""An OEM wrapper archive must not read as an empty bug report.
|
||||
|
||||
MIUI / HyperOS hands out a zip of app logs with the real
|
||||
`bugreport-<device>-<timestamp>.zip` nested inside. The outer archive has
|
||||
none of the entry points the modules read, so every module
|
||||
reported it found nothing and the command still exited 0 — an empty analysis
|
||||
that looks like a finished one.
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
|
||||
DUMPSTATE = "== dumpstate: 2026-01-01 00:00:00\nDUMP OF SERVICE package:\n"
|
||||
|
||||
|
||||
def _inner_zip() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as inner:
|
||||
inner.writestr("main_entry.txt", "bugreport-test-2026-01-01-00-00-00.txt")
|
||||
inner.writestr("bugreport-test-2026-01-01-00-00-00.txt", DUMPSTATE)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _wrapper_zip() -> zipfile.ZipFile:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as outer:
|
||||
outer.writestr("app_logs/hilog.txt", "unrelated OEM log\n")
|
||||
outer.writestr("bugreport-test-2026-01-01-00-00-00.zip", _inner_zip())
|
||||
return zipfile.ZipFile(io.BytesIO(buffer.getvalue()))
|
||||
|
||||
|
||||
class TestCheckBugreportWrapper:
|
||||
def test_nested_bugreport_is_used(self, tmp_path):
|
||||
cmd = CmdAndroidCheckBugreport(results_path=str(tmp_path))
|
||||
cmd.from_zip(_wrapper_zip())
|
||||
module = cmd.modules[0](results_path=str(tmp_path))
|
||||
cmd.module_init(module)
|
||||
assert "main_entry.txt" in module.zip_files
|
||||
|
||||
def test_plain_bugreport_is_left_alone(self, tmp_path):
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("main_entry.txt", "bugreport.txt")
|
||||
archive.writestr("bugreport.txt", DUMPSTATE)
|
||||
archive.writestr("attachments/extra.zip", _inner_zip())
|
||||
cmd = CmdAndroidCheckBugreport(results_path=str(tmp_path))
|
||||
cmd.from_zip(zipfile.ZipFile(io.BytesIO(buffer.getvalue())))
|
||||
module = cmd.modules[0](results_path=str(tmp_path))
|
||||
cmd.module_init(module)
|
||||
assert "attachments/extra.zip" in module.zip_files
|
||||
@@ -6,27 +6,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
from mvt.android.modules.androidqf.aqf_settings import AQFSettings
|
||||
from mvt.android.artifacts.settings import Settings
|
||||
from mvt.common.module import run_module
|
||||
|
||||
from ..utils import get_android_androidqf, list_files
|
||||
|
||||
|
||||
class TestSettingsModule:
|
||||
def test_bugreport_settings_format(self):
|
||||
settings = Settings()
|
||||
settings.parse(
|
||||
"GLOBAL SETTINGS (user 0)\n"
|
||||
"_id:1 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true\n"
|
||||
"SECURE SETTINGS (user 10)\n"
|
||||
"_id:2 name:accessibility_enabled pkg:android value:1\n"
|
||||
)
|
||||
|
||||
assert settings.results == {
|
||||
"global:user_0": {"adb_wifi_enabled": "0"},
|
||||
"secure:user_10": {"accessibility_enabled": "1"},
|
||||
}
|
||||
|
||||
def test_parsing(self):
|
||||
data_path = get_android_androidqf()
|
||||
m = AQFSettings(target_path=data_path)
|
||||
@@ -34,7 +19,13 @@ class TestSettingsModule:
|
||||
parent_path = Path(data_path).absolute().parent.as_posix()
|
||||
m.from_dir(parent_path, files)
|
||||
run_module(m)
|
||||
assert len(m.results) == 1
|
||||
assert "random" in m.results.keys()
|
||||
assert len(m.results) == 9
|
||||
assert {result["namespace"] for result in m.results} == {"random"}
|
||||
assert m.results[0] == {
|
||||
"namespace": "random",
|
||||
"user": None,
|
||||
"name": "samsung_errorlog_agree",
|
||||
"value": "0",
|
||||
}
|
||||
assert len(m.alertstore.alerts) == 1
|
||||
assert "samsung_errorlog_agree" in m.alertstore.alerts[0].message
|
||||
@@ -10,6 +10,7 @@ from mvt.android.modules.bugreport.dumpsys_appops import DumpsysAppops
|
||||
from mvt.android.modules.bugreport.dumpsys_getprop import DumpsysGetProp
|
||||
from mvt.android.modules.bugreport.dumpsys_packages import DumpsysPackages
|
||||
from mvt.android.modules.bugreport.dumpsys_receivers import DumpsysReceivers
|
||||
from mvt.android.modules.bugreport.settings import Settings
|
||||
from mvt.android.modules.bugreport.tombstones import Tombstones
|
||||
from mvt.common.module import run_module
|
||||
|
||||
@@ -93,6 +94,25 @@ class TestBugreportAnalysis:
|
||||
assert alert.event == malicious_receiver
|
||||
assert alert.matched_indicator.value == "com.android.services"
|
||||
|
||||
def test_settings_module(self):
|
||||
m = self.launch_bug_report_module(Settings)
|
||||
assert len(m.results) == 12
|
||||
|
||||
assert len(m.alertstore.alerts) == 1
|
||||
assert "accessibility_enabled = 1" in m.alertstore.alerts[0].message
|
||||
|
||||
assert len(m.timeline) == 3
|
||||
change = [
|
||||
entry
|
||||
for entry in m.timeline
|
||||
if entry["timestamp"] == "2022-03-28 22:41:07.980000"
|
||||
][0]
|
||||
assert change["event"] == "settings_change"
|
||||
assert change["data"] == (
|
||||
'secure setting "accessibility_enabled" changed from "0" to "1" '
|
||||
"by com.example.helper"
|
||||
)
|
||||
|
||||
def test_tombstones_modules(self):
|
||||
m = self.launch_bug_report_module(Tombstones)
|
||||
assert len(m.results) == 2
|
||||
|
||||
@@ -264,5 +264,53 @@ ChangeId(143539591; name=SELINUX_LATEST_CHANGES; disabled)
|
||||
ChangeId(247079863; name=DISALLOW_INVALID_GROUP_REFERENCE; enableSinceTargetSdk=34)
|
||||
ChangeId(174227820; name=FORCE_DISABLE_HEVC_SUPPORT; disabled)
|
||||
ChangeId(168419799; name=DOWNSCALED; disabled; packageOverrides={com.google.android.apps.tachyon=false, org.torproject.torbrowser=false}; rawOverrides={org.torproject.torbrowser=false, org.article19.circulo.next=false}; overridable)
|
||||
-------------------------------------------------------------------------------
|
||||
DUMP OF SERVICE settings:
|
||||
CONFIG SETTINGS (user 0)
|
||||
_id:682 name:namespace_one/blocked_components pkg:com.example.services value:com.android.settings,com.android.vending,
|
||||
com.example.dialer,
|
||||
com.example.camera default:com.android.settings,
|
||||
com.android.vending,
|
||||
com.example.dialer defaultSystemSet:false
|
||||
_id:684 name:namespace_one/streaming_blocked_components pkg:com.example.services value:com.example.dialer,com.example.camera default:com.android.settings,
|
||||
com.android.vending defaultSystemSet:false
|
||||
_id:680 name:namespace_one/allowed_packages pkg:com.example.services value:com.example.messaging,
|
||||
com.example.chat
|
||||
|
||||
GLOBAL SETTINGS (user 0)
|
||||
_id:2070 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true
|
||||
_id:778 name:hidden_api_blacklist_exemptions value:{null}
|
||||
_id:9640 name:send_action_app_error pkg:android value:1 notPreservedInRestore
|
||||
_id:9631 name:development_settings_enabled pkg:com.android.settings value:1 default:1 defaultSystemSet:true
|
||||
History (development_settings_enabled)
|
||||
time:11-02 11:21:22.212 mode:update oldValue:null newValue:1 package:com.android.settings
|
||||
time:03-14 09:02:11.100 mode:update oldValue:1 newValue:0 package:com.example.updater
|
||||
_id:771 name:widget_instance_data pkg:com.android.systemui value:{
|
||||
"version": 1,
|
||||
"data": [
|
||||
{
|
||||
"number": 10000,
|
||||
"package_name": "com.example.widget"
|
||||
}
|
||||
]
|
||||
} defaultSystemSet:true
|
||||
_id:41654 name:widget_instance_data pkg:com.android.systemui value:{
|
||||
"version": 3,
|
||||
"data": []
|
||||
} defaultSystemSet:true
|
||||
|
||||
SECURE SETTINGS (user 0)
|
||||
_id:907 name:lock_screen_show_notifications pkg:com.android.settings value:1 default:1 defaultSystemSet:true isValuePreservedInRestore:true
|
||||
_id:240 name:accessibility_enabled pkg:android value:1 default:0 defaultSystemSet:true
|
||||
History (accessibility_enabled)
|
||||
time:03-28 22:41:07.980 mode:update oldValue:0 newValue:1 package:com.example.helper
|
||||
|
||||
SECURE SETTINGS (user 10)
|
||||
_id:311 name:accessibility_enabled pkg:android value:0 tag:null
|
||||
|
||||
GENERATION REGISTRY
|
||||
Maximum number of backing stores:8
|
||||
Number of backing stores:1
|
||||
_Backing store for type:SETTINGS_SECURE user:10 size:1024 cachedEntries:1
|
||||
|
||||
--------- 0.019s was the duration of dumpsys settings, ending at: 2022-03-29 23:14:28
|
||||
@@ -0,0 +1,16 @@
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
|
||||
currentUserId=0
|
||||
User state[
|
||||
attributes:{id=0, touchExplorationEnabled=false, installedServiceCount=5}
|
||||
installed services: {
|
||||
0 : com.google.android.apps.accessibility.voiceaccess/.JustSpeakService (A11yTool)
|
||||
1 : com.microsoft.appmanager/com.microsoft.mmx.screenmirroringsrc.accessibility.ScreenMirroringAccessibilityService
|
||||
2 : com.samsung.accessibility/.assistantmenu.serviceframework.AssistantMenuService (A11yTool)
|
||||
3 : com.samsung.accessibility/.universalswitch.UniversalSwitchService (A11yTool)
|
||||
4 : com.samsung.android.accessibility.talkback/com.samsung.android.marvin.talkback.TalkBackService (A11yTool)
|
||||
}
|
||||
Bound services:{}
|
||||
Enabled services:{{com.samsung.accessibility/.universalswitch.UniversalSwitchService}}
|
||||
Binding services:{}
|
||||
Crashed services:{}
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
@@ -197,6 +202,86 @@ class RecordingCommand(Command):
|
||||
|
||||
|
||||
class TestCommand:
|
||||
def test_listing_modules_does_not_load_indicators(self):
|
||||
with patch("mvt.common.command.Indicators.load_indicators_files") as load:
|
||||
cmd = RecordingCommand()
|
||||
cmd.list_modules()
|
||||
load.assert_not_called()
|
||||
|
||||
def test_output_folder_is_created_by_a_run_not_by_listing(self, tmp_path):
|
||||
output_path = tmp_path / "out"
|
||||
with patch("mvt.common.command.Indicators.load_indicators_files"):
|
||||
cmd = RecordingCommand(results_path=str(output_path))
|
||||
cmd.list_modules()
|
||||
assert not output_path.exists()
|
||||
cmd.run()
|
||||
assert (output_path / "command.log").is_file()
|
||||
|
||||
def test_indicators_load_once_and_are_shared(self, indicator_file, monkeypatch):
|
||||
from mvt.common.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "STIX2", "")
|
||||
monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None)
|
||||
original = Indicators.load_indicators_files
|
||||
with patch.object(
|
||||
Indicators, "load_indicators_files", autospec=True, side_effect=original
|
||||
) as load:
|
||||
cmd = RecordingCommand(ioc_files=[indicator_file])
|
||||
load.assert_not_called()
|
||||
indicators = cmd.iocs
|
||||
assert indicators.total_ioc_count == 9
|
||||
assert len(indicators.ioc_collections) == 1
|
||||
assert cmd.iocs is indicators
|
||||
child = RecordingCommand(iocs=indicators)
|
||||
assert child.iocs is indicators
|
||||
load.assert_called_once_with(indicators, [indicator_file])
|
||||
|
||||
@pytest.mark.parametrize("assign", [False, True])
|
||||
def test_supplied_empty_indicators_are_not_loaded(self, assign):
|
||||
indicators = Indicators(logging.getLogger(__name__))
|
||||
with patch.object(Indicators, "load_indicators_files") as load:
|
||||
cmd = RecordingCommand(iocs=None if assign else indicators)
|
||||
if assign:
|
||||
cmd.iocs = indicators
|
||||
assert cmd.iocs is indicators
|
||||
assert cmd.iocs.total_ioc_count == 0
|
||||
load.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("list_modules", [False, True])
|
||||
def test_backup_cli_does_not_load_indicators_before_analysis(
|
||||
self, tmp_path, list_modules
|
||||
):
|
||||
from mvt.ios.cli import check_backup
|
||||
|
||||
args = [str(tmp_path)]
|
||||
if list_modules:
|
||||
args.insert(0, "--list-modules")
|
||||
with patch.object(Indicators, "load_indicators_files") as load:
|
||||
result = CliRunner().invoke(check_backup, args)
|
||||
assert result.exit_code == (0 if list_modules else 1)
|
||||
load.assert_not_called()
|
||||
|
||||
def test_run_checks_synthetic_indicators(self, indicator_file, monkeypatch):
|
||||
from mvt.common.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "STIX2", "")
|
||||
monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None)
|
||||
|
||||
class MatchingModule(RecordingModule):
|
||||
def run(self):
|
||||
self.results = ["https://example.org/test"]
|
||||
|
||||
def check_indicators(self):
|
||||
self.detected = [
|
||||
url for url in self.results if self.indicators.check_domain(url)
|
||||
]
|
||||
|
||||
cmd = RecordingCommand(ioc_files=[indicator_file])
|
||||
cmd.modules = [MatchingModule]
|
||||
cmd.run()
|
||||
assert cmd.executed[0].detected == ["https://example.org/test"]
|
||||
assert cmd.executed[0].indicators is cmd.iocs
|
||||
|
||||
def setup_method(self):
|
||||
RecordingModule.run_order = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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 os
|
||||
|
||||
import yaml
|
||||
|
||||
from mvt.common import config
|
||||
from mvt.common.config import MVTSettings
|
||||
|
||||
|
||||
def test_env_variables_are_not_persisted_to_config_file(tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr(config, "MVT_CONFIG_FOLDER", str(tmp_path))
|
||||
monkeypatch.setattr(config, "MVT_CONFIG_PATH", str(config_path))
|
||||
monkeypatch.setenv("MVT_NETWORK_ACCESS_ALLOWED", "false")
|
||||
monkeypatch.setenv("MVT_IOS_BACKUP_PASSWORD", "env-only-password")
|
||||
|
||||
settings = MVTSettings.initialise()
|
||||
|
||||
assert os.path.isfile(config_path)
|
||||
saved = yaml.safe_load(config_path.read_text()) or {}
|
||||
assert "NETWORK_ACCESS_ALLOWED" not in saved
|
||||
assert "IOS_BACKUP_PASSWORD" not in saved
|
||||
|
||||
# The environment must still apply to the settings in use.
|
||||
assert settings.NETWORK_ACCESS_ALLOWED is False
|
||||
assert settings.IOS_BACKUP_PASSWORD == "env-only-password"
|
||||
@@ -311,3 +311,31 @@ class TestIndicators:
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([], load_default=False)
|
||||
assert ind.total_ioc_count == 9
|
||||
|
||||
def test_check_url_matches_w_prefixed_subdomain(self, tmp_path):
|
||||
import json
|
||||
|
||||
stix_file = tmp_path / "w-domain.stix2"
|
||||
stix_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"objects": [
|
||||
{
|
||||
"type": "indicator",
|
||||
"pattern": "[domain-name:value = 'web.evil.com']",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([str(stix_file)], load_default=False)
|
||||
|
||||
for url in (
|
||||
"https://web.evil.com/path",
|
||||
"https://www.web.evil.com/path",
|
||||
):
|
||||
match = ind.check_url(url)
|
||||
assert match is not None
|
||||
assert match.ioc.value == "web.evil.com"
|
||||
@@ -22,3 +22,23 @@ def test_google_maps_url_is_not_shortened(url):
|
||||
|
||||
def test_other_google_short_url_is_shortened():
|
||||
assert URL("https://goo.gl/example").check_if_shortened() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, domain",
|
||||
[
|
||||
("https://www.example.com/path", "example.com"),
|
||||
# Only the whole "www." prefix comes off, not any leading "w" or "." character.
|
||||
("https://web.example.com", "web.example.com"),
|
||||
("https://wow.com", "wow.com"),
|
||||
("https://wired.com", "wired.com"),
|
||||
("https://www.wow.com", "wow.com"),
|
||||
],
|
||||
)
|
||||
def test_get_domain_strips_only_a_whole_www_prefix(url, domain):
|
||||
assert URL(url).domain == domain
|
||||
|
||||
|
||||
def test_shortener_starting_with_w_is_detected():
|
||||
assert URL("https://w3t.org/example").check_if_shortened() is True
|
||||
assert URL("https://www.w3t.org/example").check_if_shortened() is True
|
||||
+20
-7
@@ -3,20 +3,26 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
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
|
||||
|
||||
# The suite must neither read nor write the developer's own MVT settings,
|
||||
# downloaded indicators or update-check state, and mvt.common.config saves
|
||||
# the settings file as soon as it is imported. Both folders are redirected
|
||||
# before any mvt module is imported, which is why this file imports none at
|
||||
# the top; the subprocesses the tests start inherit the variables.
|
||||
MVT_TEST_HOME = tempfile.mkdtemp(prefix="mvt-tests-")
|
||||
atexit.register(shutil.rmtree, MVT_TEST_HOME, ignore_errors=True)
|
||||
os.environ["MVT_CONFIG_FOLDER"] = os.path.join(MVT_TEST_HOME, "config")
|
||||
os.environ["MVT_DATA_FOLDER"] = os.path.join(MVT_TEST_HOME, "data")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def indicator_file(request, tmp_path_factory):
|
||||
@@ -47,6 +53,8 @@ def indicators_factory(indicator_file):
|
||||
android_property_names=[],
|
||||
files_sha256=[],
|
||||
):
|
||||
from mvt.common.indicators import Indicators
|
||||
|
||||
ind = Indicators(log=logging.getLogger())
|
||||
ind.parse_stix2(indicator_file)
|
||||
|
||||
@@ -77,6 +85,11 @@ def restore_cli_commands(monkeypatch):
|
||||
"""
|
||||
from mvt.android.cli import cli as android_cli
|
||||
from mvt.cli import cli as neutral_cli
|
||||
from mvt.common.cli_plugins import (
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||
MVT_CUSTOM_COMMANDS_ENV,
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||
)
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
|
||||
groups = (neutral_cli, ios_cli, android_cli)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.android.cli import check_bugreport
|
||||
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
|
||||
from .utils import get_artifact_folder
|
||||
|
||||
@@ -28,3 +33,90 @@ class TestCheckBugreportCommand:
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid bugreport archive" in result.output
|
||||
assert "Traceback" not in result.output
|
||||
|
||||
|
||||
PROPERTIES = (
|
||||
"------ SYSTEM PROPERTIES (getprop) ------\n"
|
||||
"[persist.sys.timezone]: [Africa/Nairobi]\n"
|
||||
"------ 0.01s was the duration of 'SYSTEM PROPERTIES' ------\n"
|
||||
)
|
||||
TOMBSTONE = "android_data/bugreport/FS/data/tombstones/tombstone_00"
|
||||
|
||||
|
||||
def _bugreport_zip(tmp_path, dumpstate=PROPERTIES):
|
||||
"""A bugreport zip holding one tombstone written at 11:38:10 device time.
|
||||
|
||||
An even second: zip entry times have a two-second resolution.
|
||||
"""
|
||||
path = tmp_path / "bugreport.zip"
|
||||
with open(os.path.join(get_artifact_folder(), TOMBSTONE), "rb") as handle:
|
||||
tombstone = handle.read()
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("main_entry.txt", "dumpstate.txt")
|
||||
archive.writestr("dumpstate.txt", dumpstate)
|
||||
entry = zipfile.ZipInfo(
|
||||
"FS/data/tombstones/tombstone_00", date_time=(2023, 3, 10, 11, 38, 10)
|
||||
)
|
||||
archive.writestr(entry, tombstone)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _tombstone_timestamp(target, **options):
|
||||
cmd = CmdAndroidCheckBugreport(
|
||||
target_path=target,
|
||||
module_name="Tombstones",
|
||||
disable_version_check=True,
|
||||
disable_indicator_check=True,
|
||||
**options,
|
||||
)
|
||||
cmd.run()
|
||||
return cmd, cmd.executed[0].results[0]["file_timestamp"]
|
||||
|
||||
|
||||
class TestCheckBugreportTimezone:
|
||||
def test_zip_entry_times_are_read_in_the_device_timezone(self, tmp_path):
|
||||
cmd, file_timestamp = _tombstone_timestamp(_bugreport_zip(tmp_path))
|
||||
|
||||
assert cmd.module_options["device_timezone"] == "Africa/Nairobi"
|
||||
# 11:38:10 in Nairobi is 08:38:10 UTC.
|
||||
assert file_timestamp == "2023-03-10 08:38:10.000000"
|
||||
|
||||
def test_timezone_option_wins_over_the_bugreport(self, tmp_path):
|
||||
_, file_timestamp = _tombstone_timestamp(
|
||||
_bugreport_zip(tmp_path), module_options={"device_timezone": "Europe/Paris"}
|
||||
)
|
||||
|
||||
assert file_timestamp == "2023-03-10 10:38:10.000000"
|
||||
|
||||
result = CliRunner().invoke(
|
||||
check_bugreport,
|
||||
["-t", "Europe/Paris", "-m", "Tombstones", _bugreport_zip(tmp_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
def test_without_a_timezone_the_wall_clock_is_kept_and_a_warning_given(
|
||||
self, tmp_path, caplog
|
||||
):
|
||||
with caplog.at_level(logging.WARNING, logger="mvt"):
|
||||
_, file_timestamp = _tombstone_timestamp(_bugreport_zip(tmp_path, ""))
|
||||
|
||||
assert file_timestamp == "2023-03-10 11:38:10.000000"
|
||||
assert "persist.sys.timezone not found" in caplog.text
|
||||
|
||||
def test_unpacked_bugreport_warns_and_reads_mtimes_as_utc(self, tmp_path, caplog):
|
||||
unpacked = tmp_path / "bugreport"
|
||||
shutil.copytree(
|
||||
os.path.join(get_artifact_folder(), "android_data/bugreport"), unpacked
|
||||
)
|
||||
instant = datetime.datetime(
|
||||
2023, 3, 10, 8, 38, 11, tzinfo=datetime.timezone.utc
|
||||
).timestamp()
|
||||
for name in ("tombstone_00", "tombstone_01"):
|
||||
os.utime(unpacked / "FS" / "data" / "tombstones" / name, (instant, instant))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mvt"):
|
||||
_, file_timestamp = _tombstone_timestamp(str(unpacked))
|
||||
|
||||
assert "unpacked bugreport" in caplog.text
|
||||
# Whatever the zone of the machine running the analysis.
|
||||
assert file_timestamp == "2023-03-10 08:38:11.000000"
|
||||
@@ -19,6 +19,14 @@ class TestCheckBackupCommand:
|
||||
result = runner.invoke(check_backup, [path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_check_logs_the_backup_path_to_the_command_log(self, tmp_path):
|
||||
path = get_ios_backup_folder()
|
||||
output_path = tmp_path / "out"
|
||||
result = CliRunner().invoke(check_backup, ["--output", str(output_path), path])
|
||||
assert result.exit_code == 0
|
||||
command_log = (output_path / "command.log").read_text(encoding="utf-8")
|
||||
assert f"Checking iTunes backup located at: {path}" in command_log
|
||||
|
||||
def test_check_finds_backup_in_subfolder(self, tmp_path, caplog):
|
||||
runner = CliRunner()
|
||||
backup_path = tmp_path / "MobileSync" / "Backup" / "device-id"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.ios.cli import check_sysdiagnose
|
||||
@@ -50,8 +54,39 @@ 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
|
||||
|
||||
|
||||
def _create_truncated_sysdiagnose_archive(tmp_path):
|
||||
folder = tmp_path / "sysdiagnose_2026.01.01_00-00-00+0000_iPhone-OS_iPhone_23A000"
|
||||
folder.mkdir()
|
||||
(folder / "sysdiagnose.log").write_bytes(os.urandom(200_000))
|
||||
archive = tmp_path / "sysdiagnose.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
tar.add(folder, arcname=folder.name)
|
||||
data = archive.read_bytes()
|
||||
archive.write_bytes(data[: len(data) // 2])
|
||||
return archive
|
||||
|
||||
|
||||
def test_check_sysdiagnose_reports_a_truncated_archive(tmp_path, caplog):
|
||||
# A download that stopped halfway ends in EOFError from gzip, which Click
|
||||
# would otherwise turn into a bare "Aborted!" with no reason given.
|
||||
archive = _create_truncated_sysdiagnose_archive(tmp_path)
|
||||
|
||||
with caplog.at_level(logging.CRITICAL, logger="mvt"):
|
||||
result = CliRunner().invoke(check_sysdiagnose, [str(archive)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Unable to read the sysdiagnose archive" in caplog.text
|
||||
assert "truncated" in caplog.text
|
||||
assert "Aborted!" not in result.output
|
||||
@@ -34,6 +34,7 @@ def _create_sysdiagnose_folder(tmp_path):
|
||||
"sysdiagnose_2024.01.02_03-04-05+0200.tar.gz", encoding="utf-8"
|
||||
)
|
||||
(folder / "report.ips").write_text('{"bug_type": 210}\nbody', encoding="utf-8")
|
||||
(folder / "._artifact.txt").write_bytes(b"\x00\x05\x16\x07AppleDouble")
|
||||
return folder
|
||||
|
||||
|
||||
@@ -45,6 +46,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,30 +62,30 @@ 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}
|
||||
]
|
||||
assert "sysdiagnose/._artifact.txt" not in command.sysdiagnose_files
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
]
|
||||
assert command.sysdiagnose_archive is None
|
||||
assert "sysdiagnose/._artifact.txt" not in command.sysdiagnose_files
|
||||
|
||||
|
||||
def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path):
|
||||
@@ -110,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()
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -6,22 +6,6 @@ resolution-markers = [
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adb-shell"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/73/d246034db6f3e374dad9a35ee3f61345a6b239d4febd2a41ab69df9936fe/adb_shell-0.4.4.tar.gz", hash = "sha256:04c305f30a2ca25d5c54b3cd6ce9bb64c36e5f07967b23b3fb6aaecc851b90b6", size = 61822, upload-time = "2023-09-01T03:48:40.348Z" }
|
||||
|
||||
[package.optional-dependencies]
|
||||
usb = [
|
||||
{ name = "libusb1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
@@ -625,14 +609,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "iphone-backup-decrypt"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/e7/bcdacdec21d628122ba240e7f742ab2175149e58672be63af55ff37a0f28/iphone_backup_decrypt-0.9.0.tar.gz", hash = "sha256:13b18fef3c8e3af627914f8c1a429bbc5555dfb0505239ba49efe99984cc0c96", size = 16125, upload-time = "2024-09-18T15:50:12.179Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/b8/a2ec2b10d792403d36bb6661fea6357179c31ece1ed3285c442e636d6335/iphone_backup_decrypt-0.10.0.tar.gz", hash = "sha256:5f2799e58f908cce28d7929dd82231abc8acd57c25b37bce6a2d6a82e018d343", size = 20000, upload-time = "2026-09-17T21:46:44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/94/64a31be93f72e0a254bde68e4cf7d24aef37a0a985754a197fa1b028a665/iphone_backup_decrypt-0.9.0-py3-none-any.whl", hash = "sha256:55b5adfafac757f58aa6444b83a4cc2c20cdd699c6ff1d2f4b549936a5dad92c", size = 15767, upload-time = "2024-09-18T15:50:10.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/10/349b2a720e012921d1457e1d1c2b921b16300a5f3570c4c64d0d3787539f/iphone_backup_decrypt-0.10.0-py3-none-any.whl", hash = "sha256:1ac4345a07b104a3eacab20932b8621cd1e0164e0f03d913e66eed6ea5ee173e", size = 17400, upload-time = "2026-09-17T21:46:42Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -777,17 +761,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libusb1"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/35/f9d2a990d092d647b47540cd229e1d68432c0f51183484ca189612a4824c/libusb1-3.4.0.tar.gz", hash = "sha256:9cf5638506d54f21bf36550d97ea63189111a23c4d8078f630103a2052135f45", size = 91206, upload-time = "2026-05-16T20:59:19.315Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/64/d4b59444e4d3b6979aa5eb58840634465a24b41a9ab03dcf8434c9b89551/libusb1-3.4.0-py3-none-any.whl", hash = "sha256:e83d034e44c3efe1c4599c6281d34bca50a38c12cab3b7b6217d583161a01ffd", size = 67373, upload-time = "2026-05-16T20:59:13.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/55/a838a4278fac4ee49bf670ddc59d621999a1cb2a3188fb13a23609ea4f06/libusb1-3.4.0-py3-none-win32.whl", hash = "sha256:0a1aa1416034690eb9dc9a895eda0fef44d853bca1053eb1de50a5906684846d", size = 129704, upload-time = "2026-05-16T20:59:15.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/08/02aecf6dad627534a5835244ece14d7f187ef430354d9b8551199934d059/libusb1-3.4.0-py3-none-win_amd64.whl", hash = "sha256:b7dcc1f324a895af6aac708bc5513a17373f97349cc2ab8a277519c788bd18ca", size = 141212, upload-time = "2026-05-16T20:59:17.286Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.10.3"
|
||||
@@ -1016,13 +989,11 @@ wheels = [
|
||||
name = "mvt"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "adb-shell", extra = ["usb"] },
|
||||
{ name = "appdirs" },
|
||||
{ name = "betterproto2" },
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "iphone-backup-decrypt" },
|
||||
{ name = "libusb1" },
|
||||
{ name = "nskeyedunarchiver" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyahocorasick" },
|
||||
@@ -1063,13 +1034,11 @@ docs = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "adb-shell", extras = ["usb"], specifier = "==0.4.4" },
|
||||
{ name = "appdirs", specifier = "==1.4.4" },
|
||||
{ name = "betterproto2", specifier = "==0.10.0" },
|
||||
{ name = "click", specifier = "==8.4.2" },
|
||||
{ name = "cryptography", specifier = "==50.0.0" },
|
||||
{ name = "iphone-backup-decrypt", specifier = "==0.9.0" },
|
||||
{ name = "libusb1", specifier = "==3.4.0" },
|
||||
{ name = "iphone-backup-decrypt", specifier = "==0.10.0" },
|
||||
{ name = "nskeyedunarchiver", specifier = "==1.5.2" },
|
||||
{ name = "packaging", specifier = "==26.3" },
|
||||
{ name = "pyahocorasick", specifier = "==2.3.1" },
|
||||
@@ -1274,15 +1243,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/96/37c50ac951bb0260ec38d8d12e5b51587ef1ef4035c279088f2771544b28/pyahocorasick-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4acb11a0a2ff10519465749d22ad70789e9fe7f81dc8fe9957a8868e499e18ab", size = 35987, upload-time = "2026-04-27T16:32:07.08Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@@ -1684,18 +1644,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.2"
|
||||
|
||||
Reference in new issue
Block a user