Compare commits

..
Author SHA1 Message Date
Janik Besendorf 0c0b1b977e Fix AndroidQF artifact discovery in root-level ZIP archives 2026-09-28 16:23:33 +02:00
7 changed files with 119 additions and 118 deletions

No files matched your search

+16 -2
View File
@@ -46,8 +46,22 @@ class AndroidQFModule(MVTModule):
self.archive = archive
self.files = files
def _get_files_by_pattern(self, pattern: str):
return fnmatch.filter(self.files, pattern)
def _get_files_by_pattern(self, pattern: str) -> List[str]:
pattern = pattern.replace("\\", "/")
patterns = [pattern]
# ZIPs may store artifacts at their root, without the acquisition
# directory prefix present in paths collected by from_dir().
if pattern.startswith("*/"):
patterns.append(pattern[2:])
return [
file_path
for file_path in self.files
if any(
fnmatch.fnmatch(file_path.replace("\\", "/"), candidate)
for candidate in patterns
)
]
def _get_device_timezone(self):
"""
@@ -48,17 +48,12 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
)
self.parse(content)
listed = unnamed = enabled_count = 0
listed = unnamed = 0
for result in self.results:
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,
'Found accessibility service "%s"', result.get("component")
)
continue
# The operator gets this per user as a LOW alert from
@@ -67,9 +62,8 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
unnamed += result.get("unnamed_service_count") or 0
self.log.info(
"Identified a total of %d accessibility services, %d reported enabled "
"(%d more stated by the dump without a component name)",
"Identified a total of %d accessibility services (%d more stated by "
"the dump without a component name)",
listed,
enabled_count,
unnamed,
)
@@ -46,23 +46,6 @@ class TestDumpsysAccessibilityArtifact:
# "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()
file = get_artifact("android_data/dumpsys_accessibility_v14_or_later.txt")
@@ -1,73 +0,0 @@
# 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
)
+60
View File
@@ -0,0 +1,60 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 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 zipfile
import pytest
from mvt.android.modules.androidqf.base import AndroidQFModule
@pytest.mark.parametrize("prefix", ["", "./", "acquisition/", "outer/acquisition/"])
@pytest.mark.parametrize(
"pattern, filename",
[
("*/packages.json", "packages.json"),
("*/getprop.txt", "getprop.txt"),
("*/ps.txt", "ps.txt"),
("*/settings_*.txt", "settings_global.txt"),
("*/files.json", "files.json"),
("*/logs/*", "logs/logcat.txt"),
("*/mounts.json", "mounts.json"),
("*/root_binaries.json", "root_binaries.json"),
],
)
def test_artifact_lookup_preserves_archive_names(tmp_path, prefix, pattern, filename):
member = prefix + filename
with zipfile.ZipFile(tmp_path / "acquisition.zip", "w") as archive:
archive.writestr(member, b"artifact")
archive.writestr(prefix + "unrelated.txt", b"unrelated")
module = AndroidQFModule()
module.from_zip(archive, archive.namelist())
assert module._get_files_by_pattern(pattern) == [member]
assert module._get_file_content(member) == b"artifact"
def test_artifact_lookup_preserves_windows_paths():
module = AndroidQFModule()
module.from_dir("parent", [r"acquisition\logs\logcat.txt"])
assert module._get_files_by_pattern("*/logs/*") == [r"acquisition\logs\logcat.txt"]
def test_artifact_lookup_preserves_order_without_duplicates():
module = AndroidQFModule()
module.files = [
"acquisition/settings_global.txt",
"settings_secure.txt",
"./settings_system.txt",
"not_settings_global.txt",
"settings_global.txt.bak",
]
assert module._get_files_by_pattern("*/settings_*.txt") == module.files[:3]
assert module._get_files_by_pattern("settings_secure.txt") == [
"settings_secure.txt"
]
assert module._get_files_by_pattern("*/missing.txt") == []
@@ -1,16 +0,0 @@
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:{}
+39
View File
@@ -10,6 +10,7 @@ import shutil
import tempfile
import zipfile
import pytest
from click.testing import CliRunner
from mvt.android.cli import check_androidqf
@@ -23,6 +24,44 @@ from .utils import get_artifact_folder
TEST_BACKUP_PASSWORD = "123456"
@pytest.mark.parametrize(
"layout", ["directory", "", "./", "acquisition/", "outer/acquisition/"]
)
def test_androidqf_property_detection_across_layouts(
tmp_path, indicators_factory, monkeypatch, layout
):
monkeypatch.setattr(settings, "NETWORK_ACCESS_ALLOWED", False)
content = (
"[persist.sys.timezone]: [Europe/Berlin]\n"
"[test.suspicious.property]: [enabled]\n"
)
if layout == "directory":
target = tmp_path / "acquisition"
target.mkdir()
(target / "getprop.txt").write_text(content)
else:
target = tmp_path / "acquisition.zip"
with zipfile.ZipFile(target, "w") as archive:
archive.writestr(layout + "getprop.txt", content)
command = CmdAndroidCheckAndroidQF(
target_path=str(target),
module_name="AQFGetProp",
iocs=indicators_factory(android_property_names=["test.suspicious.property"]),
)
command.run()
assert len(command.executed) == 1
module = command.executed[0]
assert module.results == [
{"name": "persist.sys.timezone", "value": "Europe/Berlin"},
{"name": "test.suspicious.property", "value": "enabled"},
]
assert module._get_device_timezone() == "Europe/Berlin"
assert len(command.alertstore.alerts) == 1
assert command.alertstore.alerts[0].event == module.results[1]
class TestCheckAndroidqfCommand:
def test_log_timestamps_module_is_registered(self):
assert AQFLogTimestamps in ANDROIDQF_MODULES