1
mirror of https://github.com/mvt-project/mvt synced 2025-11-13 01:37:36 +01:00

Compare commits

..

2 Commits

Author SHA1 Message Date
tek
79d565712c Fixes code 2025-06-19 23:47:54 +02:00
makitos666
ee34bcff92 Update global_preferences.py
Added a warning for lockdown mode when the property is not present
2025-06-19 17:02:15 +02:00
16 changed files with 113 additions and 520 deletions

View File

@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

View File

@@ -23,7 +23,7 @@ install:
python3 -m pip install --upgrade -e .
test-requirements:
python3 -m pip install --upgrade --group dev
python3 -m pip install --upgrade -r test-requirements.txt
generate-proto-parsers:
# Generate python parsers for protobuf files

View File

@@ -1,5 +1,5 @@
mkdocs==1.6.1
mkdocs-autorefs==1.4.3
mkdocs-material==9.6.20
mkdocs-autorefs==1.2.0
mkdocs-material==9.5.42
mkdocs-material-extensions==1.3.1
mkdocstrings==0.30.1
mkdocstrings==0.23.0

View File

@@ -1,11 +1,13 @@
[project]
name = "mvt"
dynamic = ["version"]
authors = [{ name = "Claudio Guarnieri", email = "nex@nex.sx" }]
authors = [
{name = "Claudio Guarnieri", email = "nex@nex.sx"}
]
maintainers = [
{ name = "Etienne Maynier", email = "tek@randhome.io" },
{ name = "Donncha Ó Cearbhaill", email = "donncha.ocearbhaill@amnesty.org" },
{ name = "Rory Flynn", email = "rory.flynn@amnesty.org" },
{name = "Etienne Maynier", email = "tek@randhome.io"},
{name = "Donncha Ó Cearbhaill", email = "donncha.ocearbhaill@amnesty.org"},
{name = "Rory Flynn", email = "rory.flynn@amnesty.org"}
]
description = "Mobile Verification Toolkit"
readme = "README.md"
@@ -14,28 +16,27 @@ classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Information Technology",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python"
]
dependencies = [
"click==8.2.1",
"rich==14.1.0",
"rich==14.0.0",
"tld==0.13.1",
"requests==2.32.4",
"requests==2.32.2",
"simplejson==3.20.1",
"packaging==25.0",
"appdirs==1.4.4",
"iOSbackup==0.9.925",
"adb-shell[usb]==0.4.4",
"libusb1==3.3.1",
"cryptography==45.0.6",
"cryptography==45.0.3",
"PyYAML>=6.0.2",
"pyahocorasick==2.2.0",
"pyahocorasick==2.1.0",
"betterproto==1.2.5",
"pydantic==2.11.7",
"pydantic-settings==2.10.1",
"pydantic==2.11.5",
"pydantic-settings==2.9.1",
"NSKeyedUnArchiver==1.5.2",
"python-dateutil==2.9.0.post0",
"tzdata==2025.2",
]
requires-python = ">= 3.10"
@@ -44,31 +45,20 @@ homepage = "https://docs.mvt.re/en/latest/"
repository = "https://github.com/mvt-project/mvt"
[project.scripts]
mvt-ios = "mvt.ios:cli"
mvt-android = "mvt.android:cli"
[dependency-groups]
dev = [
"requests>=2.31.0",
"pytest>=7.4.3",
"pytest-cov>=4.1.0",
"pytest-github-actions-annotate-failures>=0.2.0",
"pytest-mock>=3.14.0",
"stix2>=3.0.1",
"ruff>=0.1.6",
"mypy>=1.7.1",
"betterproto[compiler]",
]
mvt-ios = "mvt.ios:cli"
mvt-android = "mvt.android:cli"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.coverage.run]
omit = ["tests/*"]
omit = [
"tests/*",
]
[tool.coverage.html]
directory = "htmlcov"
directory= "htmlcov"
[tool.mypy]
install_types = true
@@ -78,13 +68,15 @@ packages = "src"
[tool.pytest.ini_options]
addopts = "-ra -q --cov=mvt --cov-report html --junitxml=pytest.xml --cov-report=term-missing:skip-covered"
testpaths = ["tests"]
testpaths = [
"tests"
]
[tool.ruff.lint]
select = ["C90", "E", "F", "W"] # flake8 default set
select = ["C90", "E", "F", "W"] # flake8 default set
ignore = [
"E501", # don't enforce line length violations
"C901", # complex-structure
"E501", # don't enforce line length violations
"C901", # complex-structure
# These were previously ignored but don't seem to be required:
# "E265", # no-space-after-block-comment
@@ -96,14 +88,14 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # unused-import
"__init__.py" = ["F401"] # unused-import
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.setuptools]
include-package-data = true
package-dir = { "" = "src" }
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
@@ -112,4 +104,4 @@ where = ["src"]
mvt = ["ios/data/*.json"]
[tool.setuptools.dynamic]
version = { attr = "mvt.common.version.MVT_VERSION" }
version = {attr = "mvt.common.version.MVT_VERSION"}

View File

@@ -1,186 +0,0 @@
# 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 typing import Any
from .artifact import AndroidArtifact
SUSPICIOUS_MOUNT_POINTS = [
"/system",
"/vendor",
"/product",
"/system_ext",
]
SUSPICIOUS_OPTIONS = [
"rw",
"remount",
"noatime",
"nodiratime",
]
ALLOWLIST_NOATIME = [
"/system_dlkm",
"/system_ext",
"/product",
"/vendor",
"/vendor_dlkm",
]
class Mounts(AndroidArtifact):
"""
This artifact parses mount information from /proc/mounts or similar mount data.
It can detect potentially suspicious mount configurations that may indicate
a rooted or compromised device.
"""
def parse(self, entry: str) -> None:
"""
Parse mount information from the provided entry.
Examples:
/dev/block/bootdevice/by-name/system /system ext4 ro,seclabel,relatime 0 0
/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)
"""
self.results: list[dict[str, Any]] = []
for line in entry.splitlines():
line = line.strip()
if not line:
continue
device = None
mount_point = None
filesystem_type = None
mount_options = ""
if " on " in line and " type " in line:
try:
# Format: device on mount_point type filesystem_type (options)
device_part, rest = line.split(" on ", 1)
device = device_part.strip()
# Split by 'type' to get mount_point and filesystem info
mount_part, fs_part = rest.split(" type ", 1)
mount_point = mount_part.strip()
# Parse filesystem and options
if "(" in fs_part and fs_part.endswith(")"):
# Format: filesystem_type (options)
fs_and_opts = fs_part.strip()
paren_idx = fs_and_opts.find("(")
filesystem_type = fs_and_opts[:paren_idx].strip()
mount_options = fs_and_opts[paren_idx + 1 : -1].strip()
else:
# No options in parentheses, just filesystem type
filesystem_type = fs_part.strip()
mount_options = ""
# Skip if we don't have essential info
if not device or not mount_point or not filesystem_type:
continue
# Parse options into list
options_list = (
[opt.strip() for opt in mount_options.split(",") if opt.strip()]
if mount_options
else []
)
# Check if it's a system partition
is_system_partition = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
mount_point.startswith(sp) for sp in SUSPICIOUS_MOUNT_POINTS
)
# Check if it's mounted read-write
is_read_write = "rw" in options_list
mount_entry = {
"device": device,
"mount_point": mount_point,
"filesystem_type": filesystem_type,
"mount_options": mount_options,
"options_list": options_list,
"is_system_partition": is_system_partition,
"is_read_write": is_read_write,
}
self.results.append(mount_entry)
except ValueError:
# If parsing fails, skip this line
continue
else:
# Skip lines that don't match expected format
continue
def check_indicators(self) -> None:
"""
Check for suspicious mount configurations that may indicate root access
or other security concerns.
"""
system_rw_mounts = []
suspicious_mounts = []
for mount in self.results:
mount_point = mount["mount_point"]
options = mount["options_list"]
# Check for system partitions mounted as read-write
if mount["is_system_partition"] and mount["is_read_write"]:
system_rw_mounts.append(mount)
if mount_point == "/system":
self.log.warning(
"Root detected /system partition is mounted as read-write (rw). "
)
else:
self.log.warning(
"System partition %s is mounted as read-write (rw). This may indicate system modifications.",
mount_point,
)
# Check for other suspicious mount options
suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS]
if suspicious_opts and mount["is_system_partition"]:
if (
"noatime" in mount["mount_options"]
and mount["mount_point"] in ALLOWLIST_NOATIME
):
continue
suspicious_mounts.append(mount)
self.log.warning(
"Suspicious mount options found for %s: %s",
mount_point,
", ".join(suspicious_opts),
)
# Log interesting mount information
if mount_point == "/data" or mount_point.startswith("/sdcard"):
self.log.info(
"Data partition: %s mounted as %s with options: %s",
mount_point,
mount["filesystem_type"],
mount["mount_options"],
)
self.log.info("Parsed %d mount entries", len(self.results))
# Check indicators if available
if not self.indicators:
return
for mount in self.results:
# Check if any mount points match indicators
ioc = self.indicators.check_file_path(mount.get("mount_point", ""))
if ioc:
mount["matched_indicator"] = ioc
self.detected.append(mount)
# Check device paths for indicators
ioc = self.indicators.check_file_path(mount.get("device", ""))
if ioc:
mount["matched_indicator"] = ioc
self.detected.append(mount)

View File

@@ -51,6 +51,11 @@ ANDROID_DANGEROUS_SETTINGS = [
"key": "send_action_app_error",
"safe_value": "1",
},
{
"description": "enabled installation of non Google Play apps",
"key": "install_non_market_apps",
"safe_value": "0",
},
{
"description": "enabled accessibility services",
"key": "accessibility_enabled",

View File

@@ -53,7 +53,7 @@ class TombstoneCrashResult(pydantic.BaseModel):
file_name: str
file_timestamp: str # We store the timestamp as a string to avoid timezone issues
build_fingerprint: str
revision: str
revision: int
arch: Optional[str] = None
timestamp: str # We store the timestamp as a string to avoid timezone issues
process_uptime: Optional[int] = None
@@ -70,7 +70,7 @@ class TombstoneCrashResult(pydantic.BaseModel):
class TombstoneCrashArtifact(AndroidArtifact):
"""
""" "
Parser for Android tombstone crash files.
This parser can parse both text and protobuf tombstone crash files.
@@ -121,7 +121,9 @@ class TombstoneCrashArtifact(AndroidArtifact):
def parse_protobuf(
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> None:
"""Parse Android tombstone crash files from a protobuf object."""
"""
Parse Android tombstone crash files from a protobuf object.
"""
tombstone_pb = Tombstone().parse(data)
tombstone_dict = tombstone_pb.to_dict(
betterproto.Casing.SNAKE, include_default_values=True
@@ -142,23 +144,21 @@ class TombstoneCrashArtifact(AndroidArtifact):
def parse(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> None:
"""Parse text Android tombstone crash files."""
"""
Parse text Android tombstone crash files.
"""
# Split the tombstone file into a dictonary
tombstone_dict = {
"file_name": file_name,
"file_timestamp": convert_datetime_to_iso(file_timestamp),
}
lines = content.decode("utf-8").splitlines()
for line_num, line in enumerate(lines, 1):
for line in lines:
if not line.strip() or TOMBSTONE_DELIMITER in line:
continue
try:
for key, destination_key in TOMBSTONE_TEXT_KEY_MAPPINGS.items():
if self._parse_tombstone_line(
line, key, destination_key, tombstone_dict
):
break
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {str(e)}")
for key, destination_key in TOMBSTONE_TEXT_KEY_MAPPINGS.items():
self._parse_tombstone_line(line, key, destination_key, tombstone_dict)
# Validate the tombstone and add it to the results
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
@@ -168,7 +168,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
self, line: str, key: str, destination_key: str, tombstone: dict
) -> bool:
if not line.startswith(f"{key}"):
return False
return None
if key == "pid":
return self._load_pid_line(line, tombstone)
@@ -187,7 +187,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
raise ValueError(f"Expected key {key}, got {line_key}")
value_clean = value.strip().strip("'")
if destination_key == "uid":
if destination_key in ["uid", "revision"]:
tombstone[destination_key] = int(value_clean)
elif destination_key == "process_uptime":
# eg. "Process uptime: 40s"
@@ -200,50 +200,51 @@ class TombstoneCrashArtifact(AndroidArtifact):
return True
def _load_pid_line(self, line: str, tombstone: dict) -> bool:
try:
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
process_info = parts[0]
pid_part, tid_part, name_part = [part.strip() for part in line.split(",")]
# Parse pid, tid, name from process info
info_parts = [p.strip() for p in process_info.split(",")]
for info in info_parts:
key, value = info.split(":", 1)
key = key.strip()
value = value.strip()
pid_key, pid_value = pid_part.split(":", 1)
if pid_key != "pid":
raise ValueError(f"Expected key pid, got {pid_key}")
pid_value = int(pid_value.strip())
if key == "pid":
tombstone["pid"] = int(value)
elif key == "tid":
tombstone["tid"] = int(value)
elif key == "name":
tombstone["process_name"] = value
tid_key, tid_value = tid_part.split(":", 1)
if tid_key != "tid":
raise ValueError(f"Expected key tid, got {tid_key}")
tid_value = int(tid_value.strip())
# Extract binary path if it exists
if len(parts) > 1:
tombstone["binary_path"] = parts[1].strip().rstrip(" <")
name_key, name_value = name_part.split(":", 1)
if name_key != "name":
raise ValueError(f"Expected key name, got {name_key}")
name_value = name_value.strip()
process_name, binary_path = self._parse_process_name(name_value, tombstone)
return True
tombstone["pid"] = pid_value
tombstone["tid"] = tid_value
tombstone["process_name"] = process_name
tombstone["binary_path"] = binary_path
return True
except Exception as e:
raise ValueError(f"Failed to parse PID line: {str(e)}")
def _parse_process_name(self, process_name_part, tombstone: dict) -> bool:
process_name, process_path = process_name_part.split(">>>")
process_name = process_name.strip()
binary_path = process_path.strip().split(" ")[0]
return process_name, binary_path
def _load_signal_line(self, line: str, tombstone: dict) -> bool:
signal_part, code_part = map(str.strip, line.split(",")[:2])
signal, code, _ = [part.strip() for part in line.split(",", 2)]
signal = signal.split("signal ")[1]
signal_code, signal_name = signal.split(" ")
signal_name = signal_name.strip("()")
def parse_part(part: str, prefix: str) -> tuple[int, str]:
match = part.split(prefix)[1]
number = int(match.split()[0])
name = match.split("(")[1].split(")")[0] if "(" in match else "UNKNOWN"
return number, name
signal_number, signal_name = parse_part(signal_part, "signal ")
code_number, code_name = parse_part(code_part, "code ")
code_part = code.split("code ")[1]
code_number, code_name = code_part.split(" ")
code_name = code_name.strip("()")
tombstone["signal_info"] = {
"code": code_number,
"code": int(code_number),
"code_name": code_name,
"name": signal_name,
"number": signal_number,
"number": int(signal_code),
}
return True
@@ -255,6 +256,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
@staticmethod
def _parse_timestamp_string(timestamp: str) -> str:
timestamp_parsed = parser.parse(timestamp)
# HACK: Swap the local timestamp to UTC, so keep the original time and avoid timezone conversion.
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
return convert_datetime_to_iso(local_timestamp)

View File

@@ -107,7 +107,8 @@ class Packages(AndroidExtraction):
result["matched_indicator"] = ioc
self.detected.append(result)
def check_virustotal(self, packages: list) -> None:
@staticmethod
def check_virustotal(packages: list) -> None:
hashes = []
for package in packages:
for file in package.get("files", []):
@@ -142,15 +143,8 @@ class Packages(AndroidExtraction):
for package in packages:
for file in package.get("files", []):
if "package_name" in package:
row = [package["package_name"], file["path"]]
elif "name" in package:
row = [package["name"], file["path"]]
else:
self.log.error(
f"Package {package} has no name or package_name. packages.json or apks.json is malformed"
)
continue
row = [package["package_name"], file["path"]]
if file["sha256"] in detections:
detection = detections[file["sha256"]]
positives = detection.split("/")[0]

View File

@@ -19,7 +19,6 @@ from .processes import Processes
from .settings import Settings
from .sms import SMS
from .files import Files
from .mounts import Mounts
ANDROIDQF_MODULES = [
DumpsysActivities,
@@ -38,5 +37,4 @@ ANDROIDQF_MODULES = [
SMS,
DumpsysPackages,
Files,
Mounts,
]

View File

@@ -1,74 +0,0 @@
# 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/
import logging
import json
from typing import Optional
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
from .base import AndroidQFModule
class Mounts(MountsArtifact, AndroidQFModule):
"""This module extracts and analyzes mount information from AndroidQF acquisitions."""
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[list] = 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 = []
def run(self) -> None:
"""
Run the mounts analysis module.
This module looks for mount information files collected by androidqf
and analyzes them for suspicious configurations, particularly focusing
on detecting root access indicators like /system mounted as read-write.
"""
mount_files = self._get_files_by_pattern("*/mounts.json")
if not mount_files:
self.log.info("No mount information file found")
return
self.log.info("Found mount information file: %s", mount_files[0])
try:
data = self._get_file_content(mount_files[0]).decode(
"utf-8", errors="replace"
)
except Exception as exc:
self.log.error("Failed to read mount information file: %s", exc)
return
# Parse the mount data
try:
json_data = json.loads(data)
if isinstance(json_data, list):
# AndroidQF format: array of strings like
# "/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)"
mount_content = "\n".join(json_data)
self.parse(mount_content)
except Exception as exc:
self.log.error("Failed to parse mount information: %s", exc)
return
self.log.info("Extracted a total of %d mount entries", len(self.results))

View File

@@ -231,7 +231,6 @@ def parse_sms_file(data):
entry.pop("mms_body")
body = entry.get("body", None)
message_links = None
if body:
message_links = check_for_links(entry["body"])

View File

@@ -895,10 +895,6 @@
"version": "15.8.4",
"build": "19H390"
},
{
"version": "15.8.5",
"build": "19H394"
},
{
"build": "20A362",
"version": "16.0"
@@ -1004,10 +1000,6 @@
"version": "16.7.11",
"build": "20H360"
},
{
"version": "16.7.12",
"build": "20H364"
},
{
"version": "17.0",
"build": "21A327"
@@ -1139,29 +1131,5 @@
{
"version": "18.5",
"build": "22F76"
},
{
"version": "18.6",
"build": "22G86"
},
{
"version": "18.6.1",
"build": "22G90"
},
{
"version": "18.6.2",
"build": "22G100"
},
{
"version": "18.7",
"build": "22H20"
},
{
"version": "26",
"build": "23A341"
},
{
"version": "26.0.1",
"build": "23A355"
}
]

View File

@@ -95,17 +95,14 @@ class SafariBrowserState(IOSExtraction):
)
except sqlite3.OperationalError:
# Old version iOS <12 likely
try:
cur.execute(
"""
SELECT
title, url, user_visible_url, last_viewed_time, session_data
FROM tabs
ORDER BY last_viewed_time;
cur.execute(
"""
)
except sqlite3.OperationalError as e:
self.log.error(f"Error executing query: {e}")
SELECT
title, url, user_visible_url, last_viewed_time, session_data
FROM tabs
ORDER BY last_viewed_time;
"""
)
for row in cur:
session_entries = []

View File

@@ -116,16 +116,13 @@ class TCC(IOSExtraction):
)
db_version = "v2"
except sqlite3.OperationalError:
try:
cur.execute(
"""SELECT
service, client, client_type, allowed,
prompt_count
FROM access;"""
)
db_version = "v1"
except sqlite3.OperationalError as e:
self.log.error(f"Error parsing TCC database: {e}")
cur.execute(
"""SELECT
service, client, client_type, allowed,
prompt_count
FROM access;"""
)
db_version = "v1"
for row in cur:
service = row[0]

9
test-requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
requests>=2.31.0
pytest>=7.4.3
pytest-cov>=4.1.0
pytest-github-actions-annotate-failures>=0.2.0
pytest-mock>=3.14.0
stix2>=3.0.1
ruff>=0.1.6
mypy>=1.7.1
betterproto[compiler]

View File

@@ -1,97 +0,0 @@
# 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/
import logging
from pathlib import Path
from mvt.common.module import run_module
from ..utils import get_android_androidqf, list_files
class TestAndroidqfMountsArtifact:
def test_parse_mounts_token_checks(self):
"""
Test the artifact-level `parse` method using tolerant token checks.
Different parser variants may place mount tokens into different dict
keys (for example `mount_options`, `pass_num`, `dump_freq`, etc.). To
avoid brittle assertions we concatenate each parsed entry's values and
look for expected tokens (device names, mount points, options) somewhere
in the combined representation.
"""
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
m = MountsArtifact()
mount_lines = [
"/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)",
"/dev/block/by-name/system on /system type ext4 (rw,seclabel,noatime)",
"/dev/block/by-name/data on /data type f2fs (rw,nosuid,nodev,noatime)",
]
mount_content = "\n".join(mount_lines)
# Parse the mount lines (artifact-level)
m.parse(mount_content)
# Basic sanity: parser should return one entry per input line
assert len(m.results) == 3, f"Expected 3 parsed mounts, got: {m.results}"
# Concatenate each entry's values into a single string so token checks
# are tolerant to which dict keys were used by the parser.
def concat_values(entry):
parts = []
for v in entry.values():
try:
parts.append(str(v))
except Exception:
# Skip values that can't be stringified
continue
return " ".join(parts)
concatenated = [concat_values(e) for e in m.results]
# Token expectations (tolerant):
# - Root line should include 'dm-12' and 'noatime' (and typically 'ro')
assert any("dm-12" in s and "noatime" in s for s in concatenated), (
f"No root-like tokens (dm-12 + noatime) found in parsed results: {concatenated}"
)
# - System line should include '/system' or 'by-name/system' and 'rw'
assert any(
(("by-name/system" in s or "/system" in s) and "rw" in s)
for s in concatenated
), (
f"No system-like tokens (system + rw) found in parsed results: {concatenated}"
)
# - Data line should include '/data' or 'by-name/data' and 'rw'
assert any(
(("by-name/data" in s or "/data" in s) and "rw" in s) for s in concatenated
), f"No data-like tokens (data + rw) found in parsed results: {concatenated}"
class TestAndroidqfMountsModule:
def test_androidqf_module_no_mounts_file(self):
"""
When no `mounts.json` is present in the androidqf dataset, the module
should not produce results nor detections.
"""
from mvt.android.modules.androidqf.mounts import Mounts
data_path = get_android_androidqf()
m = Mounts(target_path=data_path, log=logging)
files = list_files(data_path)
parent_path = Path(data_path).absolute().parent.as_posix()
m.from_folder(parent_path, files)
run_module(m)
# The provided androidqf test dataset does not include mounts.json, so
# results should remain empty.
assert len(m.results) == 0, (
f"Expected no results when mounts.json is absent, got: {m.results}"
)
assert len(m.detected) == 0, f"Expected no detections, got: {m.detected}"