mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-08 09:01:13 +02:00
Track accessibility state sections and unnamed services per user
Two issues from review: * The set of printed state sections was global, so a section printed for one user decided the flags of another. A user with an installed list and no enabled section was marked enabled=False and got a LOW "installed, not enabled" alert. Sections are now tracked per user. * A count-only record was added only when a user had no named component at all. A dump stating installedServiceCount=2 and naming one service read as complete. The count-only record is now added whenever the stated count exceeds the distinct named components for that user, and carries the difference in a new unnamed_service_count field. The module summary adds up that remainder.
This commit is contained in:
1 parent
bdbc07a3b3
commit
ad6caa155f
5 files changed
+121
-22
No files matched your search
@@ -24,18 +24,26 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
|||||||
"crashed",
|
"crashed",
|
||||||
"accessibility_tool",
|
"accessibility_tool",
|
||||||
"installed_service_count",
|
"installed_service_count",
|
||||||
|
"unnamed_service_count",
|
||||||
)
|
)
|
||||||
|
|
||||||
def check_indicators(self) -> None:
|
def check_indicators(self) -> None:
|
||||||
for result in self.results:
|
for result in self.results:
|
||||||
# A stated count with no component names is a coverage statement,
|
# A stated count the dump does not back with component names is a
|
||||||
# not a service: low, but not silent.
|
# coverage statement, not a service: low, but not silent.
|
||||||
if not result.get("component"):
|
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(
|
self.alertstore.low(
|
||||||
f"The accessibility dump states "
|
f"The accessibility dump states {stated} installed "
|
||||||
f"{result['installed_service_count']} installed "
|
f"service(s) for user {result['user_id']} but {detail}",
|
||||||
f"service(s) for user {result['user_id']} but does not "
|
|
||||||
f"list their component names",
|
|
||||||
"",
|
"",
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
@@ -76,7 +84,9 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
|||||||
|
|
||||||
self.results: list[dict[str, Any]] = []
|
self.results: list[dict[str, Any]] = []
|
||||||
services: dict[tuple[int | None, str], dict] = {}
|
services: dict[tuple[int | None, str], dict] = {}
|
||||||
seen_states: set[str] = set()
|
# 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
|
# `installedServiceCount=N` from the user's `attributes:{…}` line is on
|
||||||
# most builds the only statement about installed services in the dump:
|
# most builds the only statement about installed services in the dump:
|
||||||
# few print the `installed services: {…}` block.
|
# few print the `installed services: {…}` block.
|
||||||
@@ -100,7 +110,7 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
|||||||
)
|
)
|
||||||
if state_match:
|
if state_match:
|
||||||
state = state_match.group(1).lower()
|
state = state_match.group(1).lower()
|
||||||
seen_states.add(self._state_field(state))
|
seen_states.setdefault(user_id, set()).add(self._state_field(state))
|
||||||
inline = state_match.group(2)
|
inline = state_match.group(2)
|
||||||
for component in re.findall(
|
for component in re.findall(
|
||||||
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
|
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
|
||||||
@@ -133,21 +143,24 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
|||||||
# empty: the first says nothing, the second says nothing is enabled.
|
# empty: the first says nothing, the second says nothing is enabled.
|
||||||
# Defaulting every flag to False would turn "not stated" into "not
|
# Defaulting every flag to False would turn "not stated" into "not
|
||||||
# enabled". Flags for sections this dump never printed stay None.
|
# enabled". Flags for sections this dump never printed stay None.
|
||||||
|
named: dict[int | None, int] = {}
|
||||||
for (service_user, _component), service in services.items():
|
for (service_user, _component), service in services.items():
|
||||||
|
printed = seen_states.get(service_user, set())
|
||||||
for state in ("installed", "enabled", "binding", "bound", "crashed"):
|
for state in ("installed", "enabled", "binding", "bound", "crashed"):
|
||||||
if self._state_field(state) not in seen_states:
|
if self._state_field(state) not in printed:
|
||||||
service[self._state_field(state)] = None
|
service[self._state_field(state)] = None
|
||||||
service["installed_service_count"] = installed_counts.get(service_user)
|
service["installed_service_count"] = installed_counts.get(service_user)
|
||||||
|
named[service_user] = named.get(service_user, 0) + 1
|
||||||
|
|
||||||
self.results.extend(services.values())
|
self.results.extend(services.values())
|
||||||
|
|
||||||
# A stated count whose services were never listed would leave no trace:
|
# A stated count the named services do not add up to would leave no
|
||||||
# the module would log "a total of 0" about a dump that said five.
|
# trace of the rest: the module would log "a total of 0" about a dump
|
||||||
listed_users = {service_user for service_user, _component in services}
|
# that said five, or "a total of 1" about one that said two.
|
||||||
for count_user, count in installed_counts.items():
|
for count_user, count in installed_counts.items():
|
||||||
if count_user in listed_users or count == 0:
|
unnamed = count - named.get(count_user, 0)
|
||||||
continue
|
if unnamed > 0:
|
||||||
self.results.append(self._new_unlisted(count_user, count))
|
self.results.append(self._new_unlisted(count_user, count, unnamed))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _describe_state(result: dict) -> str:
|
def _describe_state(result: dict) -> str:
|
||||||
@@ -164,14 +177,16 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
|||||||
return {"binding": "binding", "bound": "bound"}.get(state, state)
|
return {"binding": "binding", "bound": "bound"}.get(state, state)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _new_unlisted(user_id: int | None, count: int) -> dict:
|
def _new_unlisted(user_id: int | None, count: int, unnamed: int) -> dict:
|
||||||
"""The dump's own count for a user whose services it did not list.
|
"""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.
|
Every other field stays unknown: the dump named no service to carry it.
|
||||||
"""
|
"""
|
||||||
record: dict[str, Any] = dict.fromkeys(DumpsysAccessibilityArtifact._FIELDS)
|
record: dict[str, Any] = dict.fromkeys(DumpsysAccessibilityArtifact._FIELDS)
|
||||||
record["user_id"] = user_id
|
record["user_id"] = user_id
|
||||||
record["installed_service_count"] = count
|
record["installed_service_count"] = count
|
||||||
|
record["unnamed_service_count"] = unnamed
|
||||||
return record
|
return record
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -184,4 +199,5 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
|||||||
record["package_name"], record["service_name"] = component.split("/", 1)
|
record["package_name"], record["service_name"] = component.split("/", 1)
|
||||||
# Filled in after parsing: the count is per user.
|
# Filled in after parsing: the count is per user.
|
||||||
record["installed_service_count"] = None
|
record["installed_service_count"] = None
|
||||||
|
record["unnamed_service_count"] = None
|
||||||
return record
|
return record
|
||||||
@@ -48,7 +48,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
|||||||
)
|
)
|
||||||
self.parse(content)
|
self.parse(content)
|
||||||
|
|
||||||
listed = stated = 0
|
listed = unnamed = 0
|
||||||
for result in self.results:
|
for result in self.results:
|
||||||
if result.get("component"):
|
if result.get("component"):
|
||||||
listed += 1
|
listed += 1
|
||||||
@@ -59,11 +59,11 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
|||||||
# The operator gets this per user as a LOW alert from
|
# The operator gets this per user as a LOW alert from
|
||||||
# check_indicators(); here it only has to survive into the summary,
|
# check_indicators(); here it only has to survive into the summary,
|
||||||
# so that a stated count never reads as "a total of 0".
|
# so that a stated count never reads as "a total of 0".
|
||||||
stated += result.get("installed_service_count") or 0
|
unnamed += result.get("unnamed_service_count") or 0
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Identified a total of %d accessibility services (%d more stated by "
|
"Identified a total of %d accessibility services (%d more stated by "
|
||||||
"the dump without a component name)",
|
"the dump without a component name)",
|
||||||
listed,
|
listed,
|
||||||
stated,
|
unnamed,
|
||||||
)
|
)
|
||||||
@@ -35,7 +35,9 @@ class TestDumpsysAccessibilityArtifact:
|
|||||||
|
|
||||||
assert len(da.results) == 0
|
assert len(da.results) == 0
|
||||||
da.parse(data)
|
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]["package_name"] == "com.malware.accessibility"
|
||||||
assert da.results[0]["service_name"] == "com.malware.service.malwareservice"
|
assert da.results[0]["service_name"] == "com.malware.service.malwareservice"
|
||||||
assert da.results[0]["enabled"] is True
|
assert da.results[0]["enabled"] is True
|
||||||
@@ -53,9 +55,11 @@ class TestDumpsysAccessibilityArtifact:
|
|||||||
|
|
||||||
da.check_indicators()
|
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].level == AlertLevel.MEDIUM
|
||||||
assert da.alertstore.alerts[0].event == da.results[0]
|
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):
|
def test_same_component_is_kept_for_each_user(self):
|
||||||
da = DumpsysAccessibilityArtifact()
|
da = DumpsysAccessibilityArtifact()
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ accessibility services" about a dump that said there are five.
|
|||||||
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
|
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
|
||||||
from mvt.common.alerts import AlertLevel
|
from mvt.common.alerts import AlertLevel
|
||||||
|
|
||||||
|
from ..utils import get_artifact
|
||||||
|
|
||||||
AOSP_NO_LIST = """\
|
AOSP_NO_LIST = """\
|
||||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||||
User state[
|
User state[
|
||||||
@@ -43,6 +45,14 @@ User state[attributes:{id=95, installedServiceCount=3}
|
|||||||
Enabled services:{}
|
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 = """\
|
ZERO_COUNT = """\
|
||||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||||
User state[attributes:{id=0, installedServiceCount=0}
|
User state[attributes:{id=0, installedServiceCount=0}
|
||||||
@@ -98,3 +108,42 @@ class TestAccessibilityInstalledServiceCount:
|
|||||||
# "Zero installed" is a negative result the empty section already
|
# "Zero installed" is a negative result the empty section already
|
||||||
# states; a record for it would be noise.
|
# states; a record for it would be noise.
|
||||||
assert _parse(ZERO_COUNT).results == []
|
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
|
||||||
|
)
|
||||||
@@ -42,6 +42,18 @@ User state[attributes:{id=0, currentUser=true}
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 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:
|
class _IndicatorsMatching:
|
||||||
"""Minimal stand-in: matches one package id, like the STIX2 loader would."""
|
"""Minimal stand-in: matches one package id, like the STIX2 loader would."""
|
||||||
@@ -123,3 +135,21 @@ class TestAccessibilityServiceState:
|
|||||||
artifact.check_indicators()
|
artifact.check_indicators()
|
||||||
assert artifact.alertstore.count(AlertLevel.CRITICAL) == 1
|
assert artifact.alertstore.count(AlertLevel.CRITICAL) == 1
|
||||||
assert artifact.alertstore.count(AlertLevel.LOW) == 0
|
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
|
||||||
|
)
|
||||||
Reference in new issue
Block a user