mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-22 15:18:26 +02:00
Compare commits
18
Commits
No files matched your search
@@ -37,8 +37,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # the package version comes from the git tags
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1" # Mondays, 09:00 UTC
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
if: github.ref_type != 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: |
|
||||
last=$(git describe --tags --abbrev=0 --match 'v*')
|
||||
if [ "$(git rev-list "$last..HEAD" --count)" = 0 ]; then
|
||||
echo "Nothing merged since $last"; exit 0
|
||||
fi
|
||||
tag=v$(date -u +%Y.%-m.%-d)
|
||||
gh release create "$tag" --target "$GITHUB_SHA" --generate-notes
|
||||
# Events made with GITHUB_TOKEN don't start other workflows; run them by hand.
|
||||
gh workflow run release.yml --ref "$tag"
|
||||
gh workflow run publish-release-docker.yml --ref "$tag"
|
||||
|
||||
publish:
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # PyPI trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: astral-sh/setup-uv@v10.0.0
|
||||
- run: uv build && uv publish
|
||||
@@ -5,10 +5,6 @@ on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # coverage comment
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Run Python Tests
|
||||
@@ -17,10 +13,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
env:
|
||||
# Takes precedence over .python-version, which otherwise makes `uv run`
|
||||
# rebuild the venv with 3.10 and test every matrix entry on 3.10.
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -34,23 +26,16 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --locked --group dev
|
||||
uv sync --locked --group dev --python ${{ matrix.python-version }}
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
set -o pipefail
|
||||
make test-ci | tee pytest-coverage.txt
|
||||
- name: Coverage job summary
|
||||
run: uv run coverage report --format=markdown --show-missing --skip-covered >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Pytest coverage comment
|
||||
# One comment per PR, not one per matrix entry. PRs from forks get a
|
||||
# read-only token and can't post; the job summary above still works.
|
||||
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
|
||||
continue-on-error: true
|
||||
uses: MishaKav/pytest-coverage-comment@v1.12.2
|
||||
continue-on-error: true # Workflows running on a fork can't post comments
|
||||
uses: MishaKav/pytest-coverage-comment@main
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
pytest-coverage-path: ./pytest-coverage.txt
|
||||
junitxml-path: ./pytest.xml
|
||||
# The full table with per-line links exceeds GitHub's 65536-char comment limit.
|
||||
report-only-changed-files: true
|
||||
remove-links-to-lines: true
|
||||
@@ -122,7 +122,6 @@ RUN apt-get update \
|
||||
binutils \
|
||||
default-jre-headless \
|
||||
file \
|
||||
git \
|
||||
jq \
|
||||
less \
|
||||
libcurl4 \
|
||||
|
||||
@@ -32,3 +32,8 @@ clean:
|
||||
dist:
|
||||
$(UV) build
|
||||
|
||||
upload:
|
||||
$(UV) tool run twine upload dist/*
|
||||
|
||||
test-upload:
|
||||
$(UV) tool run twine upload --repository testpypi dist/*
|
||||
@@ -73,7 +73,11 @@ rather than in MVT's own `config.yaml`.
|
||||
|
||||
### Commands on `mvt`
|
||||
|
||||
A MVT plugin command can also add sub-commands to the base `mvt` command. This can be used for commands which are not tied to a particular forensic platform:
|
||||
The `mvt` command hosts what belongs to neither platform: `version`,
|
||||
`completion`, `plugins` and `download-iocs`. A plugin command which is not
|
||||
about the acquisition of one platform, such as one which configures the plugin
|
||||
or synchronizes the indicators it uses, belongs there too, in the
|
||||
`mvt.cli_plugins` group:
|
||||
|
||||
```toml
|
||||
[project.entry-points."mvt.cli_plugins"]
|
||||
|
||||
@@ -78,9 +78,7 @@ MVT logs a warning. The nine pairs are:
|
||||
|
||||
`check-iocs` re-checks stored results rather than an acquisition. It matches
|
||||
every `<slug>.json` file in the results folder to the module with that slug.
|
||||
It then runs that module's `check_indicators()` again. A module which
|
||||
implements `check_indicators()` is included in `check-iocs` for its platform.
|
||||
It does not need to declare the `check-iocs` pair.
|
||||
It then runs that module's `check_indicators()` again.
|
||||
|
||||
### Writing a module
|
||||
|
||||
@@ -178,61 +176,6 @@ not, MVT skips the module with a warning.
|
||||
They are the same records it writes to `<slug>.json`. Typed results per module
|
||||
are planned.
|
||||
|
||||
### Replacing a built-in module
|
||||
|
||||
A custom module which extends a built-in one runs alongside it, and when the
|
||||
two share a slug they write to the same results file. MVT warns about that
|
||||
collision, naming both modules, where each came from and the file the later one
|
||||
overwrites, but it runs them both. Set `replaces` to the class the module
|
||||
supersedes to take that module's place instead: when both are available to a
|
||||
command, the named module is dropped from the run.
|
||||
|
||||
```python
|
||||
from mvt.ios.modules.backup import Manifest as BuiltinManifest
|
||||
|
||||
|
||||
class Manifest(BuiltinManifest):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = BuiltinManifest
|
||||
```
|
||||
|
||||
Keeping the class name of the replaced module, as above, keeps the name
|
||||
`--module` selects the module by and the slug its results file is named after.
|
||||
A replacement with a different class name writes to a file named after its own
|
||||
slug, unless it sets `slug` to the slug of the module it replaces, and
|
||||
`--module` still selects it by the name of the replaced module, which MVT logs.
|
||||
Taking over the slug of a replaced module is not a collision and is not warned
|
||||
about, because that module is no longer part of the run.
|
||||
|
||||
Modules which depend on the replaced module receive the replacement instead, so
|
||||
`get_dependency_results(BuiltinManifest)` returns the replacement's results. A
|
||||
module must not depend on the module it replaces: that module does not run, so
|
||||
the replacement has to produce the data itself, and MVT logs a warning when a
|
||||
module declares both.
|
||||
|
||||
A replacement takes on the obligations of the module it replaces. If it
|
||||
declares a dependency the command does not provide, it is skipped like any
|
||||
other module with an unavailable dependency, and the module it replaced stays
|
||||
out of the run: neither of them produces results, and the modules depending on
|
||||
the replaced module are skipped as well.
|
||||
|
||||
Subclassing the replaced module is not required, but a replacement which is not
|
||||
a subclass is logged with a warning, because its results may not be what the
|
||||
modules depending on the replaced module expect. Naming a module the command
|
||||
does not run has no effect, and modules which replace each other in a cycle all
|
||||
keep running and replace nothing. Every applied substitution is logged, so it
|
||||
is recorded in `command.log` when the command runs with an `--output` folder.
|
||||
|
||||
Every command resolves replacements on its own. `check-iocs` matches stored
|
||||
results files against the slugs of the modules available for that command. A
|
||||
replacement which subclasses the module it replaces inherits its
|
||||
`check_indicators()`. It is then part of `check-iocs` for its platform and
|
||||
re-checks the results file named after its slug. A replacement with no
|
||||
`check_indicators()` is not part of `check-iocs`. The built-in module it
|
||||
replaced re-checks the file. `check-iocs` matches `--module` on the class name
|
||||
only. Pass a differently named replacement's own name there, not the name of
|
||||
the module it replaces.
|
||||
|
||||
### Importing from MVT
|
||||
|
||||
Import from `mvt.plugin` if it has what you need. The names it exports are
|
||||
|
||||
@@ -435,11 +435,3 @@ 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,13 +1,10 @@
|
||||
# Check an iOS Sysdiagnose
|
||||
|
||||
`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
|
||||
`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
|
||||
[plugin packages](../development/index.md#installed-module-packages) which
|
||||
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.
|
||||
declare support for it. Install at least one such package first.
|
||||
|
||||
The command accepts either an extracted sysdiagnose directory or the original
|
||||
gzip-compressed tar archive.
|
||||
|
||||
+4
-6
@@ -24,8 +24,7 @@ dependencies = [
|
||||
"simplejson==4.1.1",
|
||||
"packaging==26.3",
|
||||
"appdirs==1.4.4",
|
||||
"iphone_backup_decrypt==0.9.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
"iOSbackup==0.9.925",
|
||||
"adb-shell[usb]==0.4.4",
|
||||
"libusb1==3.4.0",
|
||||
"cryptography==50.0.0",
|
||||
@@ -73,7 +72,7 @@ docs = [
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.coverage.run]
|
||||
@@ -122,6 +121,5 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
mvt = ["ios/data/*.json"]
|
||||
|
||||
[tool.setuptools_scm]
|
||||
# The version is the latest v* tag; ignore the archive/* tags.
|
||||
git_describe_command = "git describe --dirty --tags --long --match 'v*'"
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "mvt.common.version.MVT_VERSION" }
|
||||
@@ -4,7 +4,6 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
@@ -21,10 +20,10 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
self.alertstore.medium(
|
||||
f'Found accessibility service: "{result["component"]}"',
|
||||
f'Found accessibility service: "{result["service"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
)
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
"""
|
||||
@@ -34,69 +33,41 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
:param content: content of the accessibility section (string)
|
||||
"""
|
||||
|
||||
self.results: list[dict[str, Any]] = []
|
||||
services: dict[tuple[int | None, str], dict] = {}
|
||||
user_id: int | None = None
|
||||
state: str | None = None
|
||||
# "Old" syntax
|
||||
in_services = False
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("installed services:"):
|
||||
in_services = True
|
||||
continue
|
||||
|
||||
if not in_services:
|
||||
continue
|
||||
|
||||
if line.strip() == "}":
|
||||
# At end of installed services
|
||||
break
|
||||
|
||||
service = line.split(":")[1].strip()
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"package_name": service.split("/")[0],
|
||||
"service": service,
|
||||
}
|
||||
)
|
||||
|
||||
# "New" syntax - AOSP >= 14 (?)
|
||||
# Looks like:
|
||||
# Enabled services:{{com.azure.authenticator/com.microsoft.brooklyn.module.accessibility.BrooklynAccessibilityService}, {com.agilebits.onepassword/com.agilebits.onepassword.filling.accessibility.FillingAccessibilityService}}
|
||||
|
||||
for line in content.splitlines():
|
||||
user_match = re.search(r"attributes:\{id=(\d+)", line)
|
||||
if user_match:
|
||||
user_id = int(user_match.group(1))
|
||||
if line.strip().startswith("Enabled services:"):
|
||||
matches = re.finditer(r"{([^{]+?)}", line)
|
||||
|
||||
stripped = line.strip()
|
||||
state_match = re.match(
|
||||
r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)",
|
||||
stripped,
|
||||
)
|
||||
if state_match:
|
||||
state = state_match.group(1).lower()
|
||||
inline = state_match.group(2)
|
||||
for component in re.findall(
|
||||
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
|
||||
):
|
||||
service = services.setdefault(
|
||||
(user_id, component), self._new_service(component, user_id)
|
||||
for match in matches:
|
||||
# Each match is in format: <package_name>/<service>
|
||||
package_name, _, service = match.group(1).partition("/")
|
||||
|
||||
self.results.append(
|
||||
{"package_name": package_name, "service": service}
|
||||
)
|
||||
service[self._state_field(state)] = True
|
||||
service["accessibility_tool"] = "(A11yTool)" in inline
|
||||
continue
|
||||
|
||||
if not state:
|
||||
continue
|
||||
if stripped == "}" or stripped.startswith("AccessibilityInputFilter"):
|
||||
state = None
|
||||
continue
|
||||
component_match = re.search(
|
||||
r"(?:\d+\s*:\s*)?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?",
|
||||
stripped,
|
||||
)
|
||||
if component_match:
|
||||
component = component_match.group(1)
|
||||
service = services.setdefault(
|
||||
(user_id, component), self._new_service(component, user_id)
|
||||
)
|
||||
service[self._state_field(state)] = True
|
||||
service["accessibility_tool"] = "(A11yTool)" in stripped
|
||||
|
||||
self.results.extend(services.values())
|
||||
|
||||
@staticmethod
|
||||
def _state_field(state: str) -> str:
|
||||
return {"binding": "binding", "bound": "bound"}.get(state, state)
|
||||
|
||||
@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,
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
@@ -99,34 +98,6 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
|
||||
return keystore
|
||||
|
||||
def parse_binary_xml(self, data: bytes) -> list[dict]:
|
||||
"""Recover ADB key records from Android binary XML (ABX).
|
||||
|
||||
Some dumpstate implementations embed ABX in a text stream and replace
|
||||
binary token bytes. The public key remains intact, while unavailable
|
||||
numeric metadata is represented as ``None`` rather than corrupt text.
|
||||
"""
|
||||
keystore = []
|
||||
seen = set()
|
||||
for match in re.finditer(
|
||||
rb"(?<![A-Za-z0-9+/])([A-Za-z0-9+/]{300,}={0,2})"
|
||||
rb"(?: ([A-Za-z0-9_.@-]+))?",
|
||||
data,
|
||||
):
|
||||
key = match.group(1)
|
||||
try:
|
||||
base64.b64decode(key, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
full_key = key + (b" " + match.group(2) if match.group(2) else b"")
|
||||
key_info = self.calculate_key_info(full_key)
|
||||
key_info["last_connected"] = None
|
||||
keystore.append(key_info)
|
||||
return keystore
|
||||
|
||||
@staticmethod
|
||||
def calculate_key_info(user_key: bytes) -> dict:
|
||||
if b" " in user_key:
|
||||
@@ -147,7 +118,7 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
return {
|
||||
"user": user.decode("utf-8"),
|
||||
"fingerprint": key_fingerprint_colon,
|
||||
"key": key_base64.decode("ascii", errors="replace"),
|
||||
"key": key_base64,
|
||||
}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
@@ -204,23 +175,12 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
# Keystore is in XML format on some devices and we need to parse it
|
||||
if keystore_data and keystore_data.startswith(b"<?xml"):
|
||||
parsed["debugging_manager"]["keystore"] = self.parse_xml(keystore_data)
|
||||
elif keystore_data and keystore_data.startswith(b"ABX\x00"):
|
||||
parsed["debugging_manager"]["keystore"] = self.parse_binary_xml(
|
||||
keystore_data
|
||||
)
|
||||
else:
|
||||
# Keystore is not XML format
|
||||
parsed["debugging_manager"]["keystore"] = keystore_data
|
||||
|
||||
parsed = parsed["debugging_manager"]
|
||||
|
||||
for key, value in list(parsed.items()):
|
||||
if isinstance(value, bytes):
|
||||
decoded = value.decode("utf-8", errors="replace")
|
||||
parsed[key] = (
|
||||
decoded == "true" if decoded in ("true", "false") else decoded
|
||||
)
|
||||
|
||||
# Calculate key fingerprints for better readability
|
||||
key_info = []
|
||||
for user_key in parsed.get("user_keys", []):
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
@@ -28,14 +27,14 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
for entry in perm["entries"]:
|
||||
if entry.get("timestamp"):
|
||||
if "timestamp" in entry:
|
||||
records.append(
|
||||
{
|
||||
"timestamp": entry["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": entry["event"],
|
||||
"event": entry["access"],
|
||||
"data": f"{result['package_name']} access to "
|
||||
f"{perm['name']}: {entry['event']}",
|
||||
f"{perm['name']}: {entry['access']}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -52,7 +51,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
# We use a placeholder entry to create a basic alert even without permission entries.
|
||||
placeholder_entry = {"event": "unknown", "timestamp": ""}
|
||||
placeholder_entry = {"access": "Unknown", "timestamp": ""}
|
||||
|
||||
for perm in result["permissions"]:
|
||||
if (
|
||||
@@ -61,12 +60,12 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
):
|
||||
for entry in sorted(
|
||||
perm["entries"] or [placeholder_entry],
|
||||
key=lambda x: x.get("timestamp") or "",
|
||||
key=lambda x: x["timestamp"],
|
||||
):
|
||||
cleaned_result = result.copy()
|
||||
cleaned_result["permissions"] = [perm]
|
||||
self.alertstore.medium(
|
||||
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['event']}' at {entry['timestamp']}",
|
||||
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
|
||||
entry["timestamp"],
|
||||
cleaned_result,
|
||||
)
|
||||
@@ -74,151 +73,111 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
elif result["package_name"] in RISKY_PACKAGES:
|
||||
for entry in sorted(
|
||||
perm["entries"] or [placeholder_entry],
|
||||
key=lambda x: x.get("timestamp") or "",
|
||||
key=lambda x: x["timestamp"],
|
||||
):
|
||||
cleaned_result = result.copy()
|
||||
cleaned_result["permissions"] = [perm]
|
||||
self.alertstore.medium(
|
||||
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['event']}' at {entry['timestamp']}",
|
||||
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
|
||||
entry["timestamp"],
|
||||
cleaned_result,
|
||||
)
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
self.results: list[dict[str, Any]] = []
|
||||
permission: dict[str, Any] | None = None
|
||||
package: dict[str, Any] | None = None
|
||||
uid: str | None = None
|
||||
uid_details: dict[str, Any] = {}
|
||||
attribution: str | None = None
|
||||
# self.results: List[Dict[str, Any]] = []
|
||||
perm: dict[str, Any] = {}
|
||||
package: dict[str, Any] = {}
|
||||
entry: dict[str, Any] = {}
|
||||
uid = None
|
||||
in_packages = False
|
||||
|
||||
def finish_permission() -> None:
|
||||
nonlocal permission
|
||||
if package is not None and permission is not None:
|
||||
package["permissions"].append(permission)
|
||||
permission = None
|
||||
|
||||
def finish_package() -> None:
|
||||
nonlocal package
|
||||
finish_permission()
|
||||
if package is not None:
|
||||
self.results.append(package)
|
||||
package = None
|
||||
|
||||
for line in output.splitlines():
|
||||
uid_match = re.match(r"^ Uid ([^:]+):$", line)
|
||||
if uid_match:
|
||||
if line.startswith(" Uid 0:"):
|
||||
in_packages = True
|
||||
finish_package()
|
||||
uid = uid_match.group(1)
|
||||
uid_details = {
|
||||
"uid_state": None,
|
||||
"capability": None,
|
||||
"app_widget_visible": None,
|
||||
"default_modes": {},
|
||||
}
|
||||
continue
|
||||
|
||||
if not in_packages:
|
||||
continue
|
||||
|
||||
uid_property = re.match(
|
||||
r"^ (state|capability|appWidgetVisible)=(.*)$", line
|
||||
)
|
||||
if uid_property:
|
||||
key = {
|
||||
"state": "uid_state",
|
||||
"appWidgetVisible": "app_widget_visible",
|
||||
}.get(uid_property.group(1), uid_property.group(1))
|
||||
value: Any = uid_property.group(2)
|
||||
if value in ("true", "false"):
|
||||
value = value == "true"
|
||||
uid_details[key] = value
|
||||
continue
|
||||
if line.startswith(" Uid "):
|
||||
uid = line[6:-1]
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
if package:
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
|
||||
default_mode = re.match(r"^ ([A-Z0-9_]+): mode=([^\s]+)", line)
|
||||
if default_mode and package is None:
|
||||
uid_details["default_modes"][default_mode.group(1)] = (
|
||||
default_mode.group(2)
|
||||
)
|
||||
perm = {}
|
||||
self.results.append(package)
|
||||
package = {}
|
||||
continue
|
||||
|
||||
if line.startswith(" Package "):
|
||||
finish_package()
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
|
||||
if package:
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
|
||||
perm = {}
|
||||
self.results.append(package)
|
||||
|
||||
package = {
|
||||
"package_name": line[12:-1],
|
||||
"permissions": [],
|
||||
"uid": uid,
|
||||
**uid_details,
|
||||
}
|
||||
continue
|
||||
|
||||
operation_match = re.match(
|
||||
r"^ ([A-Z0-9_]+)(?: \(([^)]+)\))?:\s*$", line
|
||||
)
|
||||
if package is not None and operation_match:
|
||||
finish_permission()
|
||||
permission = {
|
||||
"name": operation_match.group(1),
|
||||
"mode": operation_match.group(2),
|
||||
"entries": [],
|
||||
}
|
||||
attribution = None
|
||||
if package and line.startswith(" ") and line[6] != " ":
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
perm = {}
|
||||
|
||||
perm["name"] = line.split()[0]
|
||||
perm["entries"] = []
|
||||
if len(line.split()) > 1:
|
||||
perm["access"] = line.split()[1][1:-2]
|
||||
|
||||
continue
|
||||
|
||||
attribution_match = re.match(r"^\s{8,}([^=]+)=\[$", line)
|
||||
if attribution_match:
|
||||
attribution = attribution_match.group(1).strip()
|
||||
continue
|
||||
if line.strip() == "]":
|
||||
attribution = None
|
||||
continue
|
||||
if line.startswith(" "):
|
||||
# Permission entry like:
|
||||
# Reject: [fg-s]2021-05-19 22:02:52.054 (-314d1h25m2s33ms)
|
||||
access_type = line.split(":")[0].strip()
|
||||
if access_type not in ["Access", "Reject"]:
|
||||
# Skipping invalid access type. Some entries are not in the format we expect
|
||||
continue
|
||||
|
||||
if permission is None:
|
||||
continue
|
||||
event_match = re.match(
|
||||
r"^\s*(Access|Reject):\s*\[([^]]+)\]\s*"
|
||||
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s*"
|
||||
r"(\([^)]*\))?(?:\s+duration=([^\s]+))?",
|
||||
line,
|
||||
)
|
||||
running_match = re.match(
|
||||
r"^\s*Running start at:\s*(\S+(?: \S+)?)",
|
||||
line,
|
||||
)
|
||||
if event_match:
|
||||
entry = {
|
||||
"event": event_match.group(1).lower(),
|
||||
"access": event_match.group(1),
|
||||
"uid_state": event_match.group(2),
|
||||
"timestamp": convert_datetime_to_iso(
|
||||
datetime.strptime(event_match.group(3), "%Y-%m-%d %H:%M:%S.%f")
|
||||
),
|
||||
"relative_time": event_match.group(4),
|
||||
"duration": event_match.group(5),
|
||||
"attribution": attribution,
|
||||
}
|
||||
permission["entries"].append(entry)
|
||||
elif running_match:
|
||||
raw_start = running_match.group(1)
|
||||
timestamp = None
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", raw_start):
|
||||
timestamp = convert_datetime_to_iso(
|
||||
datetime.strptime(raw_start, "%Y-%m-%d %H:%M:%S.%f")
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
|
||||
entry["access"] = access_type
|
||||
entry["type"] = line[line.find("[") + 1 : line.find("]")]
|
||||
|
||||
try:
|
||||
entry["timestamp"] = convert_datetime_to_iso(
|
||||
datetime.strptime(
|
||||
line[line.find("]") + 1 : line.find("(")].strip(),
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
)
|
||||
)
|
||||
permission["entries"].append(
|
||||
{
|
||||
"event": "running",
|
||||
"access": "Running",
|
||||
"uid_state": None,
|
||||
"timestamp": timestamp,
|
||||
"relative_time": raw_start
|
||||
if raw_start.startswith("+")
|
||||
else None,
|
||||
"duration": None,
|
||||
"attribution": attribution,
|
||||
}
|
||||
)
|
||||
except ValueError:
|
||||
# Invalid date format
|
||||
pass
|
||||
|
||||
finish_package()
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
if package:
|
||||
self.results.append(package)
|
||||
@@ -18,18 +18,18 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
action = record.get("action", "update")
|
||||
package_name = record["package_name"]
|
||||
vers = record["version_code"]
|
||||
vers = record["vers"]
|
||||
|
||||
if vers == 0:
|
||||
if vers == "0":
|
||||
data = f"Recorded uninstall of package {package_name} (vers 0)"
|
||||
elif action == "downgrade":
|
||||
prev_vers = record.get("previous_version_code", "unknown")
|
||||
prev_vers = record.get("previous_vers", "unknown")
|
||||
data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}"
|
||||
else:
|
||||
data = f"Recorded update of package {package_name} with vers {vers}"
|
||||
|
||||
return {
|
||||
"timestamp": record["period_start"],
|
||||
"timestamp": record["from"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "battery_daily",
|
||||
"data": data,
|
||||
@@ -48,7 +48,6 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
self.results = []
|
||||
daily = None
|
||||
daily_updates: list[dict[str, Any]] = []
|
||||
records: list[dict[str, Any]] = []
|
||||
@@ -60,10 +59,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
|
||||
timeframe = line[13:].strip()
|
||||
date_from, date_to = timeframe.strip(":").split(" to ", 1)
|
||||
daily = {
|
||||
"period_start": self._format_daily_timestamp(date_from),
|
||||
"period_end": self._format_daily_timestamp(date_to),
|
||||
}
|
||||
daily = {"from": date_from[0:10], "to": date_to[0:10]}
|
||||
continue
|
||||
|
||||
if not daily:
|
||||
@@ -74,30 +70,21 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
|
||||
line = line.strip().replace("Update ", "")
|
||||
package_name, vers = line.split(" ", 1)
|
||||
vers_raw = vers.split("=", 1)[1]
|
||||
try:
|
||||
version_code: int | str = int(vers_raw)
|
||||
except ValueError:
|
||||
version_code = vers_raw
|
||||
vers_nr = vers.split("=", 1)[1]
|
||||
|
||||
already_seen = False
|
||||
for update in daily_updates:
|
||||
if (
|
||||
package_name == update["package_name"]
|
||||
and version_code == update["version_code"]
|
||||
):
|
||||
update["occurrences"] += 1
|
||||
if package_name == update["package_name"] and vers_nr == update["vers"]:
|
||||
already_seen = True
|
||||
break
|
||||
|
||||
if not already_seen:
|
||||
update_record: dict[str, Any] = {
|
||||
"action": "update",
|
||||
"period_start": daily["period_start"],
|
||||
"period_end": daily["period_end"],
|
||||
"from": daily["from"],
|
||||
"to": daily["to"],
|
||||
"package_name": package_name,
|
||||
"version_code": version_code,
|
||||
"occurrences": 1,
|
||||
"vers": vers_nr,
|
||||
}
|
||||
|
||||
daily_updates.append(update_record)
|
||||
@@ -108,31 +95,26 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
self._detect_uninstalls_and_downgrades(records)
|
||||
self.results.extend(records)
|
||||
|
||||
@staticmethod
|
||||
def _format_daily_timestamp(value: str) -> str:
|
||||
if len(value) >= 19 and value[10] == "-":
|
||||
return f"{value[:10]} {value[11:].replace('-', ':')}"
|
||||
return value
|
||||
|
||||
def _detect_uninstalls_and_downgrades(self, records: list[dict[str, Any]]) -> None:
|
||||
def _detect_uninstalls_and_downgrades(
|
||||
self, records: list[dict[str, Any]]
|
||||
) -> None:
|
||||
package_versions: dict[str, int] = {}
|
||||
|
||||
for record in sorted(
|
||||
records,
|
||||
key=lambda record: (
|
||||
record["period_start"],
|
||||
record["period_end"],
|
||||
record["from"],
|
||||
record["to"],
|
||||
record["package_name"],
|
||||
),
|
||||
):
|
||||
package_name = record["package_name"]
|
||||
vers_nr = record["version_code"]
|
||||
vers_nr = record["vers"]
|
||||
|
||||
if vers_nr == 0:
|
||||
record["action"] = "uninstall"
|
||||
if vers_nr == "0":
|
||||
self.alertstore.medium(
|
||||
f"Detected uninstall of package {package_name} (vers 0)",
|
||||
record["period_start"],
|
||||
record["from"],
|
||||
record,
|
||||
)
|
||||
package_versions.pop(package_name, None)
|
||||
@@ -146,11 +128,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
previous_vers = package_versions.get(package_name)
|
||||
if previous_vers is not None and current_vers < previous_vers:
|
||||
record["action"] = "downgrade"
|
||||
record["previous_version_code"] = previous_vers
|
||||
record["previous_vers"] = str(previous_vers)
|
||||
self.alertstore.medium(
|
||||
f"Detected downgrade of package {package_name} "
|
||||
f"from vers {previous_vers} to vers {current_vers}",
|
||||
record["period_start"],
|
||||
record["from"],
|
||||
record,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,177 +1,96 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# 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 datetime
|
||||
import re
|
||||
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
class DumpsysBatteryHistoryArtifact(AndroidArtifact):
|
||||
"""Parser for package-related events in dumpsys batterystats history."""
|
||||
"""
|
||||
Parser for dumpsys dattery history events.
|
||||
"""
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
package_name = result.get("package_name")
|
||||
if not package_name:
|
||||
continue
|
||||
ioc_match = self.indicators.check_app_id(package_name)
|
||||
ioc_match = self.indicators.check_app_id(result["package_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_wall_time(value: str) -> datetime.datetime | None:
|
||||
if re.fullmatch(r"\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", value):
|
||||
value = f"1900-{value}"
|
||||
for date_format in (
|
||||
"%Y-%m-%d-%H-%M-%S-%f",
|
||||
"%Y-%m-%d-%H-%M-%S",
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
):
|
||||
try:
|
||||
return datetime.datetime.strptime(value, date_format)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _elapsed_seconds(value: str) -> float | None:
|
||||
if not value.startswith("+"):
|
||||
return None
|
||||
units = {"d": 86400, "h": 3600, "m": 60, "s": 1, "ms": 0.001}
|
||||
total = 0.0
|
||||
for number, unit in re.findall(r"(\d+)(ms|d|h|m|s)", value):
|
||||
total += int(number) * units[unit]
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _package_from_name(name: str) -> str | None:
|
||||
clean = name.removeprefix("*walarm*:").removeprefix("*alarm*:")
|
||||
slash_parts = clean.split("/")
|
||||
if len(slash_parts) > 1:
|
||||
first = slash_parts[0].lstrip("@")
|
||||
if first.startswith(("com.", "org.", "net.")):
|
||||
return first
|
||||
for part in reversed(slash_parts[1:]):
|
||||
candidate = part.lstrip("@").split(":", 1)[0]
|
||||
if candidate.startswith(("com.", "org.", "net.")):
|
||||
return candidate
|
||||
parts = clean.split(".")
|
||||
package_parts = []
|
||||
for part in parts:
|
||||
if part and part[0].islower():
|
||||
package_parts.append(part)
|
||||
else:
|
||||
break
|
||||
return ".".join(package_parts) if len(package_parts) >= 2 else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_service(service: str) -> str:
|
||||
# WorkManager decorates jobs with one or more scheduler prefixes.
|
||||
if "@" in service:
|
||||
candidates = [part for part in service.split("@") if "/" in part]
|
||||
if candidates:
|
||||
return candidates[-1]
|
||||
return service
|
||||
continue
|
||||
|
||||
def parse(self, data: str) -> None:
|
||||
self.results: list[dict[str, str | None]] = []
|
||||
anchor_time: datetime.datetime | None = None
|
||||
anchor_elapsed = 0.0
|
||||
has_history_heading = any(
|
||||
line.startswith("Battery History") for line in data.splitlines()
|
||||
)
|
||||
in_history = not has_history_heading
|
||||
|
||||
for line in data.splitlines():
|
||||
stripped = line.strip()
|
||||
if line.startswith("Battery History"):
|
||||
if in_history:
|
||||
break
|
||||
in_history = True
|
||||
if line.startswith("Battery History "):
|
||||
continue
|
||||
if not in_history:
|
||||
continue
|
||||
if has_history_heading and not stripped:
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
reset_match = re.search(r"(?:RESET:)?TIME:\s*(\S+)", stripped)
|
||||
if reset_match:
|
||||
parsed_time = self._parse_wall_time(reset_match.group(1))
|
||||
if parsed_time is not None:
|
||||
elapsed_token = stripped.split()[0]
|
||||
anchor_elapsed = self._elapsed_seconds(elapsed_token) or 0.0
|
||||
anchor_time = parsed_time
|
||||
continue
|
||||
|
||||
fields = stripped.split()
|
||||
if not fields:
|
||||
continue
|
||||
if len(fields) > 1 and re.fullmatch(r"\d{2}-\d{2}", fields[0]):
|
||||
time_elapsed = " ".join(fields[:2])
|
||||
line_time = self._parse_wall_time(time_elapsed)
|
||||
elapsed = None
|
||||
time_parts = line.strip().split()
|
||||
time_elapsed = time_parts[0]
|
||||
if (
|
||||
len(time_parts) > 1
|
||||
and len(time_parts[0]) == 5
|
||||
and time_parts[0][2] == "-"
|
||||
and ":" in time_parts[1]
|
||||
):
|
||||
time_elapsed = " ".join(time_parts[:2])
|
||||
|
||||
event = ""
|
||||
if line.find("+job") > 0:
|
||||
event = "start_job"
|
||||
payload = line.split("+job=", 1)[1]
|
||||
uid, separator, service = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
service = service.strip().strip('"')
|
||||
package_name = service.split("/")[0]
|
||||
elif line.find("-job") > 0:
|
||||
event = "end_job"
|
||||
payload = line.split("-job=", 1)[1]
|
||||
uid, separator, service = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
service = service.strip().strip('"')
|
||||
package_name = service.split("/")[0]
|
||||
elif line.find("+running +wake_lock=") > 0:
|
||||
payload = line.split("+running +wake_lock=", 1)[1]
|
||||
uid, separator, _ = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
event = "wake"
|
||||
service = (
|
||||
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
|
||||
)
|
||||
if service == "" or "/" not in service:
|
||||
continue
|
||||
|
||||
package_name = service.split("/")[0]
|
||||
elif (line.find("+top=") > 0) or (line.find("-top") > 0):
|
||||
if line.find("+top=") > 0:
|
||||
event = "start_top"
|
||||
top_pos = line.find("+top=")
|
||||
else:
|
||||
event = "end_top"
|
||||
top_pos = line.find("-top=")
|
||||
colon_pos = top_pos + line[top_pos:].find(":")
|
||||
uid = line[top_pos + 5 : colon_pos]
|
||||
service = ""
|
||||
package_name = line[colon_pos + 1 :].strip('"')
|
||||
else:
|
||||
time_elapsed = fields[0]
|
||||
elapsed = self._elapsed_seconds(time_elapsed)
|
||||
line_time = None
|
||||
continue
|
||||
|
||||
timestamp = line_time
|
||||
if timestamp is None and anchor_time is not None and elapsed is not None:
|
||||
timestamp = anchor_time + datetime.timedelta(
|
||||
seconds=elapsed - anchor_elapsed
|
||||
)
|
||||
|
||||
def add(
|
||||
event: str, uid: str, service: str, package_name: str | None
|
||||
) -> None:
|
||||
self.results.append(
|
||||
{
|
||||
"time_elapsed": time_elapsed,
|
||||
"timestamp": convert_datetime_to_iso(timestamp)
|
||||
if timestamp
|
||||
else None,
|
||||
"event": event,
|
||||
"uid": uid,
|
||||
"package_name": package_name,
|
||||
"service": service,
|
||||
}
|
||||
)
|
||||
|
||||
for sign, uid, raw_service in re.findall(
|
||||
r"([+-])job=([^:\s]+):\"([^\"]+)\"", line
|
||||
):
|
||||
service = self._normalize_service(raw_service)
|
||||
add(
|
||||
"start_job" if sign == "+" else "end_job",
|
||||
uid,
|
||||
service,
|
||||
self._package_from_name(service) or service.split("/", 1)[0],
|
||||
)
|
||||
|
||||
for sign, uid, package_name in re.findall(
|
||||
r"([+-])top=([^:\s]+):\"([^\"]+)\"", line
|
||||
):
|
||||
add(
|
||||
"start_top" if sign == "+" else "end_top",
|
||||
uid,
|
||||
"",
|
||||
package_name,
|
||||
)
|
||||
|
||||
wake_match = re.search(r"\+wake_lock=([^:\s]+):\"([^\"]+)\"", line)
|
||||
if wake_match:
|
||||
wake_name = wake_match.group(2)
|
||||
add(
|
||||
"wake",
|
||||
wake_match.group(1),
|
||||
wake_name,
|
||||
self._package_from_name(wake_name),
|
||||
)
|
||||
self.results.append(
|
||||
{
|
||||
"time_elapsed": time_elapsed,
|
||||
"event": event,
|
||||
"uid": uid,
|
||||
"package_name": package_name,
|
||||
"service": service,
|
||||
}
|
||||
)
|
||||
@@ -29,27 +29,16 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
rxp = re.compile(
|
||||
r"^\s*\d+:\s*\[((?:\d{4}-)?\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]\s*"
|
||||
r"(?:\[Pid:\((\d+)\)\])?\s*([\w-]+) took (\d+)ms - ([^,]+),"
|
||||
r"\s*sql=\"(.*)\"(?:, path=(.*))?$"
|
||||
)
|
||||
r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} "
|
||||
r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*"
|
||||
r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\""
|
||||
) # pylint: disable=line-too-long
|
||||
|
||||
pool: str | None = None
|
||||
connection_number: int | None = None
|
||||
is_primary: bool | None = None
|
||||
pool = None
|
||||
in_operations = False
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Connection pool for "):
|
||||
pool = line.replace("Connection pool for ", "").rstrip(":")
|
||||
in_operations = False
|
||||
|
||||
connection_match = re.match(r"\s+Connection #(\d+):", line)
|
||||
if connection_match:
|
||||
connection_number = int(connection_match.group(1))
|
||||
is_primary = None
|
||||
|
||||
if line.strip().startswith("isPrimaryConnection:"):
|
||||
is_primary = line.strip().split(":", 1)[1].strip() == "true"
|
||||
|
||||
if not pool:
|
||||
continue
|
||||
@@ -63,6 +52,7 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
|
||||
if not line.startswith(" "):
|
||||
in_operations = False
|
||||
pool = None
|
||||
continue
|
||||
|
||||
match = rxp.match(line)
|
||||
@@ -70,15 +60,11 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
result = {
|
||||
"timestamp": match.group(1),
|
||||
"pid": int(match.group(2)) if match.group(2) else None,
|
||||
"isodate": match.group(1),
|
||||
"action": match.group(3),
|
||||
"duration_ms": int(match.group(4)),
|
||||
"status": match.group(5),
|
||||
"sql": match.group(6),
|
||||
"path": match.group(7) or pool,
|
||||
"pool_path": pool,
|
||||
"connection_number": connection_number,
|
||||
"is_primary": is_primary,
|
||||
"sql": match.group(4),
|
||||
"path": pool,
|
||||
}
|
||||
if match.group(2):
|
||||
result["pid"] = match.group(2)
|
||||
self.results.append(result)
|
||||
@@ -4,7 +4,6 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
from .package_resolvers import parse_resolver_table
|
||||
|
||||
|
||||
class DumpsysPackageActivitiesArtifact(AndroidArtifact):
|
||||
@@ -20,11 +19,67 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
|
||||
)
|
||||
continue
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
def parse(self, content: str):
|
||||
"""
|
||||
Parse the Dumpsys Package section for activities
|
||||
Adds results to self.results
|
||||
|
||||
:param content: content of the package section (string)
|
||||
"""
|
||||
self.results = parse_resolver_table(content, "Activity")
|
||||
self.results = []
|
||||
|
||||
in_activity_resolver_table = False
|
||||
in_non_data_actions = False
|
||||
intent = None
|
||||
for line in content.splitlines():
|
||||
if line.startswith("Activity Resolver Table:"):
|
||||
in_activity_resolver_table = True
|
||||
continue
|
||||
|
||||
if not in_activity_resolver_table:
|
||||
continue
|
||||
|
||||
if line.startswith(" Non-Data Actions:"):
|
||||
in_non_data_actions = True
|
||||
continue
|
||||
|
||||
if not in_non_data_actions:
|
||||
continue
|
||||
|
||||
# If we hit an empty line, the Non-Data Actions section should be
|
||||
# finished.
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# We detect the action name.
|
||||
if (
|
||||
line.startswith(" " * 6)
|
||||
and not line.startswith(" " * 8)
|
||||
and ":" in line
|
||||
):
|
||||
intent = line.strip().replace(":", "")
|
||||
continue
|
||||
|
||||
# If we are not in an intent block yet, skip.
|
||||
if not intent:
|
||||
continue
|
||||
|
||||
# If we are in a block but the line does not start with 8 spaces
|
||||
# it means the block ended a new one started, so we reset and
|
||||
# continue.
|
||||
if not line.startswith(" " * 8):
|
||||
intent = None
|
||||
continue
|
||||
|
||||
# If we got this far, we are processing receivers for the
|
||||
# activities we are interested in.
|
||||
activity = line.strip().split(" ")[1]
|
||||
package_name = activity.split("/")[0]
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"intent": intent,
|
||||
"package_name": package_name,
|
||||
"activity": activity,
|
||||
}
|
||||
)
|
||||
@@ -1,10 +1,10 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# 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 re
|
||||
from typing import Any
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from mvt.android.utils import ROOT_PACKAGES
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
@@ -12,244 +12,222 @@ from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
def _value(raw: str) -> Any:
|
||||
if raw == "null":
|
||||
return None
|
||||
if raw in ("true", "false"):
|
||||
return raw == "true"
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
def check_indicators(self) -> None:
|
||||
alerted_root_packages = set()
|
||||
for result in self.results:
|
||||
package_name = result["package_name"]
|
||||
if (
|
||||
package_name in ROOT_PACKAGES
|
||||
and package_name not in alerted_root_packages
|
||||
):
|
||||
alerted_root_packages.add(package_name)
|
||||
if result["package_name"] in ROOT_PACKAGES:
|
||||
if result["package_name"] in alerted_root_packages:
|
||||
continue
|
||||
alerted_root_packages.add(result["package_name"])
|
||||
self.alertstore.medium(
|
||||
f'Found an installed package related to rooting/jailbreaking: "{package_name}"',
|
||||
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
continue
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
ioc_match = self.indicators.check_app_id(package_name)
|
||||
|
||||
ioc_match = self.indicators.check_app_id(result.get("package_name", ""))
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
timestamps = [
|
||||
("package_install", record.get("timestamp")),
|
||||
("package_last_update", record.get("last_update_time")),
|
||||
]
|
||||
timestamps.extend(
|
||||
("package_first_install", user.get("first_install_time"))
|
||||
for user in record.get("users", [])
|
||||
)
|
||||
return [
|
||||
{"event": "package_install", "timestamp": record["timestamp"]},
|
||||
{
|
||||
"timestamp": timestamp,
|
||||
"module": self.__class__.__name__,
|
||||
"event": event,
|
||||
"data": f"Install or update of package {record['package_name']}",
|
||||
}
|
||||
for event, timestamp in timestamps
|
||||
if timestamp
|
||||
"event": "package_first_install",
|
||||
"timestamp": record["first_install_time"],
|
||||
},
|
||||
{"event": "package_last_update", "timestamp": record["last_update_time"]},
|
||||
]
|
||||
|
||||
for timestamp in timestamps:
|
||||
records.append(
|
||||
{
|
||||
"timestamp": timestamp["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": timestamp["event"],
|
||||
"data": f"Install or update of package {record['package_name']}",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
@staticmethod
|
||||
def _permission(line: str, permission_type: str) -> dict:
|
||||
name, _, details = line.strip().partition(":")
|
||||
granted_match = re.search(r"granted=(true|false)", details)
|
||||
flags_match = re.search(r"flags=\[\s*([^]]*)\]", details)
|
||||
return {
|
||||
"name": name,
|
||||
"type": permission_type,
|
||||
"granted": granted_match.group(1) == "true" if granted_match else None,
|
||||
"flags": [
|
||||
flag.strip()
|
||||
for flag in (flags_match.group(1).split("|") if flags_match else [])
|
||||
if flag.strip()
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def parse_dumpsys_package_for_details(cls, output: str) -> dict[str, Any]:
|
||||
details: dict[str, Any] = {
|
||||
"app_id": None,
|
||||
"version_name": None,
|
||||
"version_code": None,
|
||||
"min_sdk": None,
|
||||
"target_sdk": None,
|
||||
"timestamp": None,
|
||||
"last_update_time": None,
|
||||
"installer": None,
|
||||
def parse_dumpsys_package_for_details(output: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse one entry of a dumpsys package information
|
||||
"""
|
||||
details: Dict[str, Any] = {
|
||||
"uid": "",
|
||||
"version_name": "",
|
||||
"version_code": "",
|
||||
"timestamp": "",
|
||||
"first_install_time": "",
|
||||
"last_update_time": "",
|
||||
"installer": "",
|
||||
"system": False,
|
||||
"permissions": [],
|
||||
"requested_permissions": [],
|
||||
"users": [],
|
||||
"permissions": list(),
|
||||
"requested_permissions": list(),
|
||||
}
|
||||
permission_section: str | None = None
|
||||
current_user: dict[str, Any] | None = None
|
||||
legacy_first_install: str | None = None
|
||||
|
||||
in_install_permissions = False
|
||||
in_runtime_permissions = False
|
||||
in_declared_permissions = False
|
||||
in_requested_permissions = True
|
||||
current_user: Optional[int] = None
|
||||
first_install_times: Dict[Optional[int], str] = {}
|
||||
runtime_permissions: Dict[Optional[int], List[Dict[str, Any]]] = {}
|
||||
for line in output.splitlines():
|
||||
stripped = line.strip()
|
||||
user_match = re.match(r"User (\d+):\s*(.*)", stripped)
|
||||
user_match = re.match(r"User (\d+):", line.strip())
|
||||
if user_match:
|
||||
current_user = {"user_id": int(user_match.group(1)), "permissions": []}
|
||||
for key, raw in re.findall(r"(\w+)=([^\s]+)", user_match.group(2)):
|
||||
clean_key = {
|
||||
"notLaunched": "not_launched",
|
||||
"installReason": "install_reason",
|
||||
"uninstallReason": "uninstall_reason",
|
||||
"dataDir": "data_dir",
|
||||
}.get(key, re.sub(r"(?<!^)(?=[A-Z])", "_", key).lower())
|
||||
current_user[clean_key] = _value(raw)
|
||||
details["users"].append(current_user)
|
||||
permission_section = None
|
||||
continue
|
||||
current_user = int(user_match.group(1))
|
||||
|
||||
header = stripped.lower()
|
||||
if header in {
|
||||
"declared permissions:",
|
||||
"install permissions:",
|
||||
"requested permissions:",
|
||||
"runtime permissions:",
|
||||
}:
|
||||
permission_section = header.split()[0]
|
||||
continue
|
||||
if in_install_permissions:
|
||||
if line.startswith(" " * 4) and not line.startswith(" " * 6):
|
||||
in_install_permissions = False
|
||||
else:
|
||||
lineinfo = line.strip().split(":")
|
||||
permission = lineinfo[0]
|
||||
granted = None
|
||||
if "granted=" in lineinfo[1]:
|
||||
granted = "granted=true" in lineinfo[1]
|
||||
|
||||
if current_user is not None:
|
||||
user_property = re.match(
|
||||
r"(installReason|uninstallReason|dataDir|firstInstallTime)=(.*)",
|
||||
stripped,
|
||||
)
|
||||
if user_property:
|
||||
key = {
|
||||
"installReason": "install_reason",
|
||||
"uninstallReason": "uninstall_reason",
|
||||
"dataDir": "data_dir",
|
||||
"firstInstallTime": "first_install_time",
|
||||
}[user_property.group(1)]
|
||||
current_user[key] = _value(user_property.group(2))
|
||||
continue
|
||||
details["permissions"].append(
|
||||
{"name": permission, "granted": granted, "type": "install"}
|
||||
)
|
||||
if in_runtime_permissions:
|
||||
if not line.startswith(" " * 8):
|
||||
in_runtime_permissions = False
|
||||
else:
|
||||
lineinfo = line.strip().split(":")
|
||||
permission = lineinfo[0]
|
||||
granted = None
|
||||
if "granted=" in lineinfo[1]:
|
||||
granted = "granted=true" in lineinfo[1]
|
||||
|
||||
if permission_section == "requested" and line.startswith(" "):
|
||||
details["requested_permissions"].append(stripped)
|
||||
continue
|
||||
if permission_section in ("declared", "install") and line.startswith(
|
||||
" "
|
||||
):
|
||||
details["permissions"].append(
|
||||
cls._permission(stripped, permission_section)
|
||||
)
|
||||
continue
|
||||
if (
|
||||
permission_section == "runtime"
|
||||
and line.startswith(" ")
|
||||
and current_user is not None
|
||||
):
|
||||
current_user["permissions"].append(cls._permission(stripped, "runtime"))
|
||||
continue
|
||||
runtime_permissions.setdefault(current_user, []).append(
|
||||
{"name": permission, "granted": granted, "type": "runtime"}
|
||||
)
|
||||
if in_declared_permissions:
|
||||
if not line.startswith(" " * 6):
|
||||
in_declared_permissions = False
|
||||
else:
|
||||
permission = line.strip().split(":")[0]
|
||||
details["permissions"].append(
|
||||
{"name": permission, "type": "declared"}
|
||||
)
|
||||
if in_requested_permissions:
|
||||
if not line.startswith(" " * 6):
|
||||
in_requested_permissions = False
|
||||
else:
|
||||
details["requested_permissions"].append(line.strip())
|
||||
if line.strip().startswith("userId="):
|
||||
details["uid"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("versionName="):
|
||||
details["version_name"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("versionCode="):
|
||||
details["version_code"] = line.split("=", 1)[1].strip()
|
||||
elif line.strip().startswith("timeStamp="):
|
||||
details["timestamp"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("installerPackageName="):
|
||||
details["installer"] = line.split("=", 1)[1].strip()
|
||||
elif line.strip().startswith("pkgFlags="):
|
||||
details["system"] = "SYSTEM" in line.split("=", 1)[1].split()
|
||||
elif line.strip().startswith("firstInstallTime="):
|
||||
first_install_times[current_user] = line.split("=", 1)[1].strip()
|
||||
elif line.strip().startswith("lastUpdateTime="):
|
||||
details["last_update_time"] = line.split("=")[1].strip()
|
||||
elif line.strip() == "install permissions:":
|
||||
in_install_permissions = True
|
||||
elif line.strip() == "runtime permissions:":
|
||||
in_runtime_permissions = True
|
||||
elif line.strip() == "declared permissions:":
|
||||
in_declared_permissions = True
|
||||
elif line.strip() == "requested permissions:":
|
||||
in_requested_permissions = True
|
||||
|
||||
simple_match = re.match(
|
||||
r"(appId|userId|versionName|timeStamp|lastUpdateTime|installerPackageName)=(.*)",
|
||||
stripped,
|
||||
)
|
||||
if simple_match:
|
||||
key = {
|
||||
"appId": "app_id",
|
||||
"userId": "app_id",
|
||||
"versionName": "version_name",
|
||||
"timeStamp": "timestamp",
|
||||
"lastUpdateTime": "last_update_time",
|
||||
"installerPackageName": "installer",
|
||||
}[simple_match.group(1)]
|
||||
raw_value = simple_match.group(2)
|
||||
details[key] = (
|
||||
_value(raw_value)
|
||||
if key == "app_id" or raw_value == "null"
|
||||
else raw_value
|
||||
)
|
||||
continue
|
||||
if stripped.startswith("pkgFlags="):
|
||||
details["system"] = "SYSTEM" in stripped.split("=", 1)[1].split()
|
||||
continue
|
||||
version_match = re.match(
|
||||
r"versionCode=([^\s]+)(?:\s+minSdk=([^\s]+))?(?:\s+targetSdk=([^\s]+))?",
|
||||
stripped,
|
||||
)
|
||||
if version_match:
|
||||
details["version_code"] = _value(version_match.group(1))
|
||||
details["min_sdk"] = (
|
||||
_value(version_match.group(2)) if version_match.group(2) else None
|
||||
)
|
||||
details["target_sdk"] = (
|
||||
_value(version_match.group(3)) if version_match.group(3) else None
|
||||
)
|
||||
elif stripped.startswith("firstInstallTime="):
|
||||
legacy_first_install = stripped.split("=", 1)[1]
|
||||
if 0 in first_install_times:
|
||||
details["first_install_time"] = first_install_times[0]
|
||||
elif None in first_install_times:
|
||||
details["first_install_time"] = first_install_times[None]
|
||||
elif first_install_times:
|
||||
details["first_install_time"] = next(iter(first_install_times.values()))
|
||||
|
||||
if legacy_first_install:
|
||||
user_zero = next(
|
||||
(user for user in details["users"] if user["user_id"] == 0), None
|
||||
)
|
||||
if user_zero is None:
|
||||
user_zero = {"user_id": 0, "permissions": []}
|
||||
details["users"].append(user_zero)
|
||||
user_zero.setdefault("first_install_time", legacy_first_install)
|
||||
if 0 in runtime_permissions:
|
||||
details["permissions"].extend(runtime_permissions[0])
|
||||
elif None in runtime_permissions:
|
||||
details["permissions"].extend(runtime_permissions[None])
|
||||
elif runtime_permissions:
|
||||
details["permissions"].extend(next(iter(runtime_permissions.values())))
|
||||
return details
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
self.results: list[dict[str, Any]] = []
|
||||
category: str | None = None
|
||||
package: dict[str, Any] | None = None
|
||||
block: list[str] = []
|
||||
def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse the dumpsys package service data
|
||||
"""
|
||||
pkg_rxp = re.compile(r" Package \[(.+?)\].*")
|
||||
|
||||
def finish() -> None:
|
||||
nonlocal package, block
|
||||
if package is not None:
|
||||
package.update(self.parse_dumpsys_package_for_details("\n".join(block)))
|
||||
self.results.append(package)
|
||||
package = None
|
||||
block = []
|
||||
results = []
|
||||
package_name = None
|
||||
package = {}
|
||||
lines: list[str] = []
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" Package ["):
|
||||
if len(lines) > 0:
|
||||
details = self.parse_dumpsys_package_for_details("\n".join(lines))
|
||||
package.update(details)
|
||||
results.append(package)
|
||||
lines = []
|
||||
package = {}
|
||||
|
||||
matches = pkg_rxp.findall(line)
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
package_name = matches[0]
|
||||
package["package_name"] = package_name
|
||||
continue
|
||||
|
||||
if not package_name:
|
||||
continue
|
||||
|
||||
lines.append(line)
|
||||
|
||||
if len(lines) > 0:
|
||||
details = self.parse_dumpsys_package_for_details("\n".join(lines))
|
||||
package.update(details)
|
||||
results.append(package)
|
||||
|
||||
return results
|
||||
|
||||
def parse(self, content: str):
|
||||
"""
|
||||
Parse the Dumpsys Package section for activities
|
||||
Adds results to self.results
|
||||
|
||||
:param content: content of the package section (string)
|
||||
"""
|
||||
self.results = []
|
||||
package = []
|
||||
|
||||
in_package_list = False
|
||||
for line in content.splitlines():
|
||||
if line == "Packages:":
|
||||
finish()
|
||||
category = "active"
|
||||
if line.startswith("Packages:"):
|
||||
in_package_list = True
|
||||
continue
|
||||
if line == "Hidden system packages:":
|
||||
finish()
|
||||
category = "hidden_system"
|
||||
|
||||
if not in_package_list:
|
||||
continue
|
||||
package_match = re.match(r"^ Package \[([^]]+)\]", line)
|
||||
if package_match and category:
|
||||
finish()
|
||||
package = {
|
||||
"package_name": package_match.group(1),
|
||||
"package_type": category,
|
||||
}
|
||||
continue
|
||||
if (
|
||||
category
|
||||
and line
|
||||
and not line.startswith(" ")
|
||||
and not line.endswith(" overlay paths:")
|
||||
):
|
||||
finish()
|
||||
category = None
|
||||
continue
|
||||
if package is not None:
|
||||
block.append(line)
|
||||
finish()
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
package.append(line)
|
||||
|
||||
self.results = self.parse_dumpsys_packages("\n".join(package))
|
||||
@@ -3,8 +3,6 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
@@ -27,37 +25,19 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, data: str) -> None:
|
||||
for line in data.splitlines():
|
||||
match = re.match(r"ChangeId\((\d+);\s*(.*)\)$", line.strip())
|
||||
if not match or "rawOverrides={" not in line:
|
||||
if not line.startswith("ChangeId(168419799; name=DOWNSCALED;"):
|
||||
continue
|
||||
body = match.group(2)
|
||||
name_match = re.search(r"(?:^|;\s*)name=([^;]+)", body)
|
||||
state = (
|
||||
"enabled"
|
||||
if re.search(r"(?:^|;\s*)enabled(?:;|$)", body)
|
||||
else "disabled"
|
||||
)
|
||||
overridable = bool(re.search(r"(?:^|;\s*)overridable(?:;|$)", body))
|
||||
overrides_field = body.split("rawOverrides={", 1)[1].split("}", 1)[0]
|
||||
for entry in overrides_field.split(","):
|
||||
package_name, separator, raw_value = entry.strip().partition("=")
|
||||
if not separator:
|
||||
continue
|
||||
value: bool | int | str
|
||||
if raw_value in ("true", "false"):
|
||||
value = raw_value == "true"
|
||||
else:
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except ValueError:
|
||||
value = raw_value
|
||||
self.results.append(
|
||||
{
|
||||
"change_id": int(match.group(1)),
|
||||
"change_name": name_match.group(1) if name_match else None,
|
||||
"change_state": state,
|
||||
"overridable": overridable,
|
||||
"package_name": package_name,
|
||||
"override_value": value,
|
||||
}
|
||||
)
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# Look for rawOverrides field
|
||||
if "rawOverrides={" in line:
|
||||
# Extract the content inside the braces for rawOverrides
|
||||
overrides_field = line.split("rawOverrides={", 1)[1].split("};", 1)[0]
|
||||
|
||||
for entry in overrides_field.split(", "):
|
||||
# Extract app name
|
||||
uninstall_app = entry.split("=")[0].strip()
|
||||
|
||||
self.results.append({"package_name": uninstall_app})
|
||||
@@ -4,7 +4,6 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
from .package_resolvers import parse_resolver_table
|
||||
|
||||
INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS"
|
||||
INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED"
|
||||
@@ -19,45 +18,115 @@ class DumpsysReceiversArtifact(AndroidArtifact):
|
||||
"""
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for receiver in self.results:
|
||||
intent = receiver["key"]
|
||||
if intent == INTENT_NEW_OUTGOING_SMS:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept outgoing SMS messages: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming SMS messages: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_DATA_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming data SMS message: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_PHONE_STATE:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring telephony state/incoming calls: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_NEW_OUTGOING_CALL:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring outgoing calls: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
for intent, receivers in self.results.items():
|
||||
for receiver in receivers:
|
||||
if intent == INTENT_NEW_OUTGOING_SMS:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept outgoing SMS messages: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming SMS messages: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_DATA_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming data SMS message: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_PHONE_STATE:
|
||||
self.log.info(
|
||||
"Found a receiver monitoring "
|
||||
'telephony state/incoming calls: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_NEW_OUTGOING_CALL:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring outgoing calls: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
if not self.indicators:
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(receiver["package_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
receiver,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
ioc_match = self.indicators.check_app_id(receiver["package_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
{intent: receiver},
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
continue
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
self.results = parse_resolver_table(output, "Receiver")
|
||||
self.results: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
in_receiver_resolver_table = False
|
||||
in_non_data_actions = False
|
||||
intent = None
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Receiver Resolver Table:"):
|
||||
in_receiver_resolver_table = True
|
||||
continue
|
||||
|
||||
if not in_receiver_resolver_table:
|
||||
continue
|
||||
|
||||
if line.startswith(" Non-Data Actions:"):
|
||||
in_non_data_actions = True
|
||||
continue
|
||||
|
||||
if not in_non_data_actions:
|
||||
continue
|
||||
|
||||
# If we hit an empty line, the Non-Data Actions section should be
|
||||
# finished.
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# We detect the action name.
|
||||
if (
|
||||
line.startswith(" " * 6)
|
||||
and not line.startswith(" " * 8)
|
||||
and ":" in line
|
||||
):
|
||||
intent = line.strip().replace(":", "")
|
||||
self.results[intent] = []
|
||||
continue
|
||||
|
||||
parts = line.strip().split(" ")
|
||||
if len(parts) < 2:
|
||||
# A single-token line here is not a receiver. Real dumpstate
|
||||
# output can print an action header mis-indented (observed with
|
||||
# 15 leading spaces instead of 6), which used to raise
|
||||
# IndexError and abort the whole module. Treat a trailing-colon
|
||||
# token as the next action, skip anything else.
|
||||
if parts[0].endswith(":"):
|
||||
intent = parts[0][:-1]
|
||||
self.results.setdefault(intent, [])
|
||||
continue
|
||||
|
||||
# If we are not in an intent block yet, skip.
|
||||
if not intent:
|
||||
continue
|
||||
|
||||
# If we are in a block but the line does not start with 8 spaces
|
||||
# it means the block ended a new one started, so we reset and
|
||||
# continue.
|
||||
if not line.startswith(" " * 8):
|
||||
intent = None
|
||||
continue
|
||||
|
||||
# If we got this far, we are processing receivers for the
|
||||
# activities we are interested in.
|
||||
receiver = parts[1]
|
||||
package_name = receiver.split("/")[0]
|
||||
|
||||
self.results[intent].append(
|
||||
{
|
||||
"package_name": package_name,
|
||||
"receiver": receiver,
|
||||
}
|
||||
)
|
||||
@@ -28,16 +28,19 @@ INTERESTING_PROPERTIES = [
|
||||
class GetProp(AndroidArtifact):
|
||||
def parse(self, entry: str) -> None:
|
||||
self.results: List[Dict[str, str]] = []
|
||||
# A property value may span several lines: persist.sys.boot.reason.history
|
||||
# prints one boot per line. Matching the whole section instead of line by
|
||||
# line lets a value run to the first closing bracket that ends a line.
|
||||
rxp = re.compile(
|
||||
r"^[ \t]*\[([^]]+)\]: \[(.*?)\][ \t\r]*$",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
rxp = re.compile(r"\[(.+?)\]: \[(.+?)\]")
|
||||
|
||||
for name, value in rxp.findall(entry):
|
||||
self.results.append({"name": name, "value": value})
|
||||
for line in entry.splitlines():
|
||||
line = line.strip()
|
||||
if line == "":
|
||||
continue
|
||||
|
||||
matches = re.findall(rxp, line)
|
||||
if not matches or len(matches[0]) != 2:
|
||||
continue
|
||||
|
||||
prop_entry = {"name": matches[0][0], "value": matches[0][1]}
|
||||
self.results.append(prop_entry)
|
||||
|
||||
def get_device_timezone(self) -> str | None:
|
||||
"""
|
||||
|
||||
@@ -117,51 +117,6 @@ class Mounts(AndroidArtifact):
|
||||
# Skip lines that don't match expected format
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def parse_mountinfo(entry: str, process_id: int) -> list[dict[str, Any]]:
|
||||
"""Parse Linux /proc/PID/mountinfo records."""
|
||||
results = []
|
||||
for line in entry.splitlines():
|
||||
fields = line.split()
|
||||
if "-" not in fields:
|
||||
continue
|
||||
separator = fields.index("-")
|
||||
if separator < 6 or len(fields) < separator + 4:
|
||||
continue
|
||||
try:
|
||||
mount_id = int(fields[0])
|
||||
parent_id = int(fields[1])
|
||||
except ValueError:
|
||||
continue
|
||||
mount_options = fields[5].split(",")
|
||||
super_options = fields[separator + 3].split(",")
|
||||
options = list(dict.fromkeys(mount_options + super_options))
|
||||
mount_point = fields[4].replace("\\040", " ")
|
||||
device = fields[separator + 2].replace("\\040", " ")
|
||||
filesystem_type = fields[separator + 1]
|
||||
is_system = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
|
||||
mount_point.startswith(f"{prefix}/")
|
||||
for prefix in SUSPICIOUS_MOUNT_POINTS
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"mount_id": mount_id,
|
||||
"parent_id": parent_id,
|
||||
"major_minor": fields[2],
|
||||
"root": fields[3].replace("\\040", " "),
|
||||
"mount_point": mount_point,
|
||||
"device": device,
|
||||
"filesystem_type": filesystem_type,
|
||||
"mount_options": ",".join(options),
|
||||
"options_list": options,
|
||||
"optional_fields": fields[6:separator],
|
||||
"is_system_partition": is_system,
|
||||
"is_read_write": "rw" in options,
|
||||
"process_ids": [process_id],
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
"""
|
||||
Check for suspicious mount configurations that may indicate root access
|
||||
|
||||
@@ -1,70 +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 re
|
||||
|
||||
|
||||
RESOLVER_TYPES = {
|
||||
"Full MIME Types": "full_mime_type",
|
||||
"Base MIME Types": "base_mime_type",
|
||||
"Wild MIME Types": "wild_mime_type",
|
||||
"Schemes": "scheme",
|
||||
"Non-Data Actions": "non_data_action",
|
||||
"MIME Typed Actions": "mime_typed_action",
|
||||
}
|
||||
|
||||
ENTRY_RE = re.compile(
|
||||
r"^\s{8,}[0-9a-fA-F]+\s+(?P<component>\S+)"
|
||||
r"(?:\s+\((?P<filters>\d+)\s+filters?\))?\s*$"
|
||||
)
|
||||
|
||||
|
||||
def parse_resolver_table(content: str, table_name: str) -> list[dict]:
|
||||
"""Parse every resolver category from a dumpsys package table."""
|
||||
results: list[dict] = []
|
||||
in_table = False
|
||||
resolver_type: str | None = None
|
||||
key: str | None = None
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.startswith(f"{table_name} Resolver Table:"):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
|
||||
if line and not line.startswith(" "):
|
||||
break
|
||||
|
||||
section_match = re.match(r"^ {2}([^ ].*):\s*$", line)
|
||||
if section_match:
|
||||
resolver_type = RESOLVER_TYPES.get(section_match.group(1))
|
||||
key = None
|
||||
continue
|
||||
if resolver_type is None:
|
||||
continue
|
||||
|
||||
key_match = re.match(r"^ {6,}([^ ].*):\s*$", line)
|
||||
if key_match:
|
||||
key = key_match.group(1)
|
||||
continue
|
||||
if key is None:
|
||||
continue
|
||||
|
||||
entry_match = ENTRY_RE.match(line)
|
||||
if not entry_match:
|
||||
continue
|
||||
component = entry_match.group("component")
|
||||
results.append(
|
||||
{
|
||||
"resolver_type": resolver_type,
|
||||
"key": key,
|
||||
"package_name": component.split("/", 1)[0],
|
||||
"component": component,
|
||||
"filter_count": int(entry_match.group("filters") or 1),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -1,91 +1,72 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# 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 .artifact import AndroidArtifact
|
||||
|
||||
|
||||
FIELD_NAMES = {
|
||||
"LABEL": "label",
|
||||
"USER": "user",
|
||||
"PID": "pid",
|
||||
"TID": "tid",
|
||||
"PPID": "ppid",
|
||||
"VSZ": "virtual_memory_size",
|
||||
"RSS": "resident_set_size",
|
||||
"WCHAN": "wchan",
|
||||
"ADDR": "address",
|
||||
"S": "state",
|
||||
"PRI": "priority",
|
||||
"NI": "nice",
|
||||
"RTPRIO": "realtime_priority",
|
||||
"SCH": "scheduler",
|
||||
"PCY": "policy",
|
||||
"TIME": "cpu_time",
|
||||
"CMD": "command",
|
||||
"NAME": "command",
|
||||
}
|
||||
INTEGER_FIELDS = {
|
||||
"pid",
|
||||
"tid",
|
||||
"ppid",
|
||||
"virtual_memory_size",
|
||||
"resident_set_size",
|
||||
"priority",
|
||||
"nice",
|
||||
}
|
||||
|
||||
|
||||
class Processes(AndroidArtifact):
|
||||
def parse(self, entry: str) -> None:
|
||||
self.results = []
|
||||
lines = [line for line in entry.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return
|
||||
headers = lines[0].split()
|
||||
if not all(header in FIELD_NAMES for header in headers):
|
||||
return
|
||||
for line in entry.splitlines()[1:]:
|
||||
proc = line.split()
|
||||
|
||||
for line in lines[1:]:
|
||||
values = line.split(None, len(headers) - 1)
|
||||
if len(values) != len(headers):
|
||||
# Skip empty lines
|
||||
if len(proc) == 0:
|
||||
continue
|
||||
result = {}
|
||||
valid = True
|
||||
for header, raw in zip(headers, values):
|
||||
key = FIELD_NAMES[header]
|
||||
value: str | int = raw.strip("[]") if key == "command" else raw
|
||||
if key in INTEGER_FIELDS:
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
valid = False
|
||||
break
|
||||
result[key] = value
|
||||
if valid:
|
||||
self.results.append(result)
|
||||
|
||||
# Sometimes WCHAN is empty.
|
||||
if len(proc) == 8:
|
||||
proc = proc[:5] + [""] + proc[5:]
|
||||
|
||||
# Sometimes there is the security label.
|
||||
if proc[0].startswith("u:r"):
|
||||
label = proc[0]
|
||||
proc = proc[1:]
|
||||
else:
|
||||
label = ""
|
||||
|
||||
# Sometimes there is no WCHAN.
|
||||
if len(proc) < 9:
|
||||
proc = proc[:5] + [""] + proc[5:]
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"user": proc[0],
|
||||
"pid": int(proc[1]),
|
||||
"ppid": int(proc[2]),
|
||||
"virtual_memory_size": int(proc[3]),
|
||||
"resident_set_size": int(proc[4]),
|
||||
"wchan": proc[5],
|
||||
"aprocress": proc[6],
|
||||
"stat": proc[7],
|
||||
"proc_name": proc[8].strip("[]"),
|
||||
"label": label,
|
||||
}
|
||||
)
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
command = result.get("command", "")
|
||||
if not isinstance(command, str):
|
||||
proc_name = result.get("proc_name", "")
|
||||
if not proc_name:
|
||||
continue
|
||||
process_name = command.rsplit("/", 1)[-1]
|
||||
if not process_name or process_name == "gatekeeperd":
|
||||
|
||||
# Skipping this process because of false positives.
|
||||
if result["proc_name"] == "gatekeeperd":
|
||||
continue
|
||||
for checker in (
|
||||
self.indicators.check_app_id,
|
||||
self.indicators.check_process,
|
||||
):
|
||||
ioc_match = checker(process_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
break
|
||||
|
||||
ioc_match = self.indicators.check_app_id(proc_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_process(proc_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
@@ -3,13 +3,6 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# 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
|
||||
|
||||
ANDROID_DANGEROUS_SETTINGS = [
|
||||
@@ -65,240 +58,23 @@ 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):
|
||||
"""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
|
||||
|
||||
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')}"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
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
|
||||
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
|
||||
@@ -131,13 +131,6 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
|
||||
) -> None:
|
||||
"""Parse Android tombstone crash files from a protobuf object."""
|
||||
self.results.append(self.parse_protobuf_record(file_name, file_timestamp, data))
|
||||
|
||||
def parse_protobuf_record(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
|
||||
) -> dict:
|
||||
if not data:
|
||||
raise ValueError("empty protobuf tombstone")
|
||||
tombstone_pb = Tombstone().parse(data)
|
||||
tombstone_dict = tombstone_pb.to_dict(
|
||||
casing=betterproto2.Casing.SNAKE, include_default_values=True
|
||||
@@ -150,31 +143,20 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
tombstone_dict["file_name"] = file_name
|
||||
tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp)
|
||||
tombstone_dict["process_name"] = self._proccess_name_from_thread(tombstone_dict)
|
||||
if isinstance(tombstone_dict.get("selinux_label"), str):
|
||||
tombstone_dict["selinux_label"] = tombstone_dict["selinux_label"].rstrip(
|
||||
"\x00"
|
||||
)
|
||||
|
||||
# Confirm the tombstone is valid, and matches the output model
|
||||
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
|
||||
return tombstone.model_dump()
|
||||
self.results.append(tombstone.model_dump())
|
||||
|
||||
def parse(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
|
||||
) -> None:
|
||||
"""Parse text Android tombstone crash files."""
|
||||
self.results.append(self.parse_text_record(file_name, file_timestamp, content))
|
||||
|
||||
def parse_text_record(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
|
||||
) -> dict:
|
||||
if not content:
|
||||
raise ValueError("empty plaintext tombstone")
|
||||
tombstone_dict = {
|
||||
"file_name": file_name,
|
||||
"file_timestamp": convert_datetime_to_iso(file_timestamp),
|
||||
}
|
||||
lines = content.decode("utf-8", errors="replace").splitlines()
|
||||
lines = content.decode("utf-8").splitlines()
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
if not line.strip() or TOMBSTONE_DELIMITER in line:
|
||||
continue
|
||||
@@ -189,7 +171,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
|
||||
# Validate the tombstone and add it to the results
|
||||
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
|
||||
return tombstone.model_dump()
|
||||
self.results.append(tombstone.model_dump())
|
||||
|
||||
def _parse_tombstone_line(
|
||||
self, line: str, key: str, destination_key: str, tombstone: dict
|
||||
@@ -209,18 +191,11 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
def _load_key_value_line(
|
||||
self, line: str, key: str, destination_key: str, tombstone: dict
|
||||
) -> bool:
|
||||
# The caller matched the key as a bare prefix, so a longer word starting
|
||||
# with it arrives here: `Caused by: …` inside an abort message reaches
|
||||
# the `Cause` key. That is a different line, not a broken file — say so
|
||||
# by declining it, and let the remaining keys have their turn. Raising
|
||||
# here discarded the whole tombstone, crash and stack trace included.
|
||||
line_key, separator, value = line.partition(":")
|
||||
if not separator or line_key != key:
|
||||
return False
|
||||
line_key, value = line.split(":", 1)
|
||||
if line_key != key:
|
||||
raise ValueError(f"Expected key {key}, got {line_key}")
|
||||
|
||||
value_clean = value.strip()
|
||||
if len(value_clean) >= 2 and value_clean[0] == value_clean[-1] == "'":
|
||||
value_clean = value_clean[1:-1]
|
||||
value_clean = value.strip().strip("'")
|
||||
if destination_key == "uid":
|
||||
tombstone[destination_key] = int(value_clean)
|
||||
elif destination_key == "process_uptime":
|
||||
@@ -294,7 +269,9 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
@staticmethod
|
||||
def _parse_timestamp_string(timestamp: str) -> str:
|
||||
timestamp_parsed = parser.parse(timestamp)
|
||||
return convert_datetime_to_iso(timestamp_parsed)
|
||||
# Preserve the source wall-clock time while returning the project-wide ISO format.
|
||||
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
|
||||
return convert_datetime_to_iso(local_timestamp)
|
||||
|
||||
@staticmethod
|
||||
def _proccess_name_from_thread(tombstone_dict: dict) -> str:
|
||||
|
||||
+10
-25
@@ -14,6 +14,7 @@ from mvt.common.cli_plugins import (
|
||||
load_cli_commands_option,
|
||||
register_cli_plugins,
|
||||
)
|
||||
from mvt.common.cmd_check_iocs import CmdCheckIOCS
|
||||
from mvt.common.help import (
|
||||
HELP_MSG_ANDROID_BACKUP_PASSWORD,
|
||||
HELP_MSG_CHECK_ADB_REMOVED,
|
||||
@@ -39,12 +40,17 @@ from mvt.common.help import (
|
||||
HELP_MSG_VERSION,
|
||||
HELP_MSG_VIRUS_TOTAL,
|
||||
)
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
from mvt.common.utils import init_logging, set_verbose_logging
|
||||
|
||||
# The commands import what they run only when they are invoked. This module is
|
||||
# imported at every start of mvt-android, including by shell completion on
|
||||
# every keystroke, so importing it must do no more than build the command tree:
|
||||
# the forensic modules, the backup parsers and the update checks stay out of it.
|
||||
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
||||
from .cmd_check_backup import CmdAndroidCheckBackup
|
||||
from .cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
||||
from .command_modules import ANDROID_CHECK_IOCS_MODULES
|
||||
from .modules.backup.helpers import cli_load_android_backup_password
|
||||
|
||||
init_logging()
|
||||
log = logging.getLogger("mvt")
|
||||
@@ -68,8 +74,6 @@ def _get_verbose(ctx):
|
||||
|
||||
|
||||
def _load_custom_modules(load_module):
|
||||
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
|
||||
|
||||
try:
|
||||
return load_custom_modules(load_module)
|
||||
except CustomModuleLoadError as exc:
|
||||
@@ -97,9 +101,6 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
from mvt.common.logo import logo
|
||||
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
@@ -163,8 +164,6 @@ def check_bugreport(
|
||||
verbose,
|
||||
bugreport_path,
|
||||
):
|
||||
from .cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
# Always generate hashes as bug reports are small.
|
||||
@@ -234,9 +233,6 @@ def check_backup(
|
||||
verbose,
|
||||
backup_path,
|
||||
):
|
||||
from .cmd_check_backup import CmdAndroidCheckBackup
|
||||
from .modules.backup.helpers import cli_load_android_backup_password
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
@@ -315,9 +311,6 @@ def check_androidqf(
|
||||
verbose,
|
||||
androidqf_path,
|
||||
):
|
||||
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
||||
from .modules.backup.helpers import cli_load_android_backup_password
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
@@ -400,8 +393,6 @@ def check_intrusion_logs(
|
||||
verbose,
|
||||
logs_path,
|
||||
):
|
||||
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
@@ -455,10 +446,6 @@ def check_intrusion_logs(
|
||||
@click.argument("FOLDER", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
from mvt.common.cmd_check_iocs import CmdCheckIOCS
|
||||
|
||||
from .command_modules import ANDROID_CHECK_IOCS_MODULES
|
||||
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdCheckIOCS(
|
||||
target_path=folder,
|
||||
@@ -485,8 +472,6 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
# ==============================================================================
|
||||
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
|
||||
def download_indicators():
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
ioc_updates.update()
|
||||
|
||||
|
||||
@@ -292,7 +292,9 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
try:
|
||||
cmd.from_ab(backup)
|
||||
except InvalidAndroidBackup as exc:
|
||||
self.log.warning("Skipping backup modules: %s", exc)
|
||||
self.log.warning(
|
||||
"Skipping backup modules as backup.ab is malformed: %s", exc
|
||||
)
|
||||
return False
|
||||
|
||||
cmd.run()
|
||||
|
||||
@@ -87,15 +87,11 @@ class CmdAndroidCheckBackup(Command):
|
||||
if header["encryption"] != "none":
|
||||
password = prompt_or_load_android_backup_password(log, self.module_options)
|
||||
if not password:
|
||||
if self.sub_command:
|
||||
raise InvalidAndroidBackup("No backup password provided")
|
||||
log.critical("No backup password provided.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
tardata = parse_backup_file(ab_file_bytes, password=password)
|
||||
except InvalidBackupPassword:
|
||||
if self.sub_command:
|
||||
raise InvalidAndroidBackup("Invalid backup password")
|
||||
log.critical("Invalid backup password")
|
||||
sys.exit(1)
|
||||
except AndroidBackupParsingError as exc:
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
# 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 .aqf_files import AQFFiles
|
||||
from .aqf_getprop import AQFGetProp
|
||||
from .aqf_log_timestamps import AQFLogTimestamps
|
||||
@@ -14,7 +12,7 @@ from .aqf_settings import AQFSettings
|
||||
from .mounts import Mounts
|
||||
from .root_binaries import RootBinaries
|
||||
|
||||
ANDROIDQF_MODULES: list[type[MVTModule]] = [
|
||||
ANDROIDQF_MODULES = [
|
||||
AQFPackages,
|
||||
AQFProcesses,
|
||||
AQFGetProp,
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
# 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
|
||||
|
||||
@@ -11,23 +15,43 @@ 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():
|
||||
name, separator, value = line.strip().partition("=")
|
||||
if not separator:
|
||||
line = line.strip()
|
||||
try:
|
||||
key, value = line.split("=", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"namespace": namespace,
|
||||
"user": None,
|
||||
"name": name,
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
try:
|
||||
self.results[namespace][key] = value
|
||||
except IndexError:
|
||||
continue
|
||||
|
||||
self.log.info("Identified %d settings", len(self.results))
|
||||
self.log.info(
|
||||
"Identified %d settings", sum([len(val) for val in self.results.values()])
|
||||
)
|
||||
@@ -3,8 +3,6 @@
|
||||
# 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 .sms import SMS
|
||||
|
||||
BACKUP_MODULES: list[type[MVTModule]] = [SMS]
|
||||
BACKUP_MODULES = [SMS]
|
||||
@@ -3,8 +3,6 @@
|
||||
# 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 .dumpsys_accessibility import DumpsysAccessibility
|
||||
from .dumpsys_activities import DumpsysActivities
|
||||
from .dumpsys_appops import DumpsysAppops
|
||||
@@ -18,11 +16,8 @@ from .dumpsys_receivers import DumpsysReceivers
|
||||
from .dumpsys_adb_state import DumpsysADBState
|
||||
from .fs_timestamps import BugReportTimestamps
|
||||
from .tombstones import Tombstones
|
||||
from .mounts import Mounts
|
||||
from .processes import Processes
|
||||
from .settings import Settings
|
||||
|
||||
BUGREPORT_MODULES: list[type[MVTModule]] = [
|
||||
BUGREPORT_MODULES = [
|
||||
DumpsysAccessibility,
|
||||
DumpsysActivities,
|
||||
DumpsysAppops,
|
||||
@@ -36,7 +31,4 @@ BUGREPORT_MODULES: list[type[MVTModule]] = [
|
||||
DumpsysADBState,
|
||||
BugReportTimestamps,
|
||||
Tombstones,
|
||||
Processes,
|
||||
Settings,
|
||||
Mounts,
|
||||
]
|
||||
@@ -72,11 +72,7 @@ class BugReportModule(MVTModule):
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
joined = os.path.join(self.extract_path, file_path)
|
||||
if (
|
||||
not Path(joined)
|
||||
.resolve()
|
||||
.is_relative_to(Path(self.extract_path).resolve())
|
||||
):
|
||||
if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()):
|
||||
raise ValueError("unsafe file_path")
|
||||
handle = open(joined, "rb")
|
||||
|
||||
@@ -104,27 +100,6 @@ class BugReportModule(MVTModule):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_command_section(content: str, heading: str) -> str:
|
||||
"""Return a bugreport command section without consuming the next one.
|
||||
|
||||
Bugreport separators include timing text, so looking for a line equal to
|
||||
``------`` is not sufficient and can accidentally feed the remainder of
|
||||
dumpstate to a parser.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
in_section = False
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if not in_section:
|
||||
if stripped.startswith(heading):
|
||||
in_section = True
|
||||
continue
|
||||
if stripped.startswith("------"):
|
||||
break
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
|
||||
if self.zip_archive:
|
||||
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
||||
|
||||
@@ -49,7 +49,9 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
||||
self.parse(content)
|
||||
|
||||
for result in self.results:
|
||||
self.log.info('Found accessibility service "%s"', result.get("component"))
|
||||
self.log.info(
|
||||
'Found installed accessibility service "%s"', result.get("service")
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Identified a total of %d accessibility services", len(self.results)
|
||||
|
||||
@@ -44,8 +44,21 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
|
||||
)
|
||||
return
|
||||
|
||||
section = self.extract_command_section(
|
||||
content.decode(errors="ignore"), "------ SYSTEM PROPERTIES"
|
||||
)
|
||||
self.parse(section)
|
||||
lines = []
|
||||
in_getprop = False
|
||||
|
||||
for line in content.decode(errors="ignore").splitlines():
|
||||
if line.strip().startswith("------ SYSTEM PROPERTIES"):
|
||||
in_getprop = True
|
||||
continue
|
||||
|
||||
if not in_getprop:
|
||||
continue
|
||||
|
||||
if line.strip() == "------":
|
||||
break
|
||||
|
||||
lines.append(line)
|
||||
|
||||
self.parse("\n".join(lines))
|
||||
self.log.info("Extracted %d Android system properties", len(self.results))
|
||||
@@ -50,10 +50,7 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
|
||||
|
||||
for result in self.results:
|
||||
dangerous_permissions_count = 0
|
||||
permissions = list(result["permissions"])
|
||||
for user in result.get("users", []):
|
||||
permissions.extend(user.get("permissions", []))
|
||||
for perm in permissions:
|
||||
for perm in result["permissions"]:
|
||||
if perm["name"] in DANGEROUS_PERMISSIONS:
|
||||
dangerous_permissions_count += 1
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
||||
"""This module extracts raw per-package compatibility overrides."""
|
||||
"""This module extracts details on uninstalled apps."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -48,4 +48,4 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
||||
)
|
||||
self.parse(content)
|
||||
|
||||
self.log.info("Found %d package compatibility overrides", len(self.results))
|
||||
self.log.info("Found %d uninstalled apps", len(self.results))
|
||||
@@ -33,7 +33,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.results = results if results else []
|
||||
self.results = results if results else {}
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
@@ -49,4 +49,4 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
self.parse(dumpsys_section)
|
||||
self.log.info("Extracted %d package receivers", len(self.results))
|
||||
self.log.info("Extracted receivers for %d intents", len(self.results))
|
||||
@@ -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,44 +38,15 @@ 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.
|
||||
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,
|
||||
"timestamp_source": (
|
||||
"zip_metadata" if self.zip_archive else "filesystem_metadata"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
import re
|
||||
|
||||
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Mounts(MountsArtifact, BugReportModule):
|
||||
"""Extract and deduplicate process mount namespaces from mountinfo."""
|
||||
|
||||
def run(self) -> None:
|
||||
mount_files = self._get_files_by_pattern("FS/proc/*/mountinfo")
|
||||
if not mount_files:
|
||||
mount_files = self._get_files_by_pattern("*/proc/*/mountinfo")
|
||||
unique: dict[tuple, dict] = {}
|
||||
for file_path in mount_files:
|
||||
pid_match = re.search(r"/proc/(\d+)/mountinfo$", file_path)
|
||||
if not pid_match:
|
||||
continue
|
||||
entries = self.parse_mountinfo(
|
||||
self._get_file_content(file_path).decode("utf-8", errors="replace"),
|
||||
int(pid_match.group(1)),
|
||||
)
|
||||
for entry in entries:
|
||||
identity = (
|
||||
entry["major_minor"],
|
||||
entry["root"],
|
||||
entry["mount_point"],
|
||||
entry["device"],
|
||||
entry["filesystem_type"],
|
||||
entry["mount_options"],
|
||||
)
|
||||
if identity in unique:
|
||||
unique[identity]["process_ids"].extend(entry["process_ids"])
|
||||
else:
|
||||
unique[identity] = entry
|
||||
self.results = list(unique.values())
|
||||
self.log.info("Extracted %d unique mount records", len(self.results))
|
||||
@@ -1,22 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
from mvt.android.artifacts.processes import Processes as ProcessesArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Processes(ProcessesArtifact, BugReportModule):
|
||||
"""Extract the process and thread table from dumpstate."""
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
self.log.error("Unable to find dumpstate file")
|
||||
return
|
||||
section = self.extract_command_section(
|
||||
data.decode("utf-8", errors="replace"),
|
||||
"------ PROCESSES AND THREADS",
|
||||
)
|
||||
self.parse(section)
|
||||
self.log.info("Identified %d running process threads", len(self.results))
|
||||
@@ -1,21 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Settings(SettingsArtifact, BugReportModule):
|
||||
"""Extract all SettingsProvider namespaces and users."""
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
self.log.error("Unable to find dumpstate file")
|
||||
return
|
||||
section = self.extract_dumpsys_section(
|
||||
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:"
|
||||
)
|
||||
self.parse(section)
|
||||
self.log.info("Identified %d Android settings", len(self.results))
|
||||
@@ -43,76 +43,21 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
|
||||
)
|
||||
return
|
||||
|
||||
grouped: dict[str, dict[str, str]] = {}
|
||||
for file_path in tombstone_files:
|
||||
file_name = file_path.rsplit("/", 1)[-1]
|
||||
source = "protobuf" if file_name.endswith(".pb") else "text"
|
||||
crash_id = file_name.removesuffix(".pb")
|
||||
grouped.setdefault(crash_id, {})[source] = file_path
|
||||
for tombstone_file in sorted(tombstone_files):
|
||||
tombstone_filename = tombstone_file.split("/")[-1]
|
||||
modification_time = self._get_file_modification_time(tombstone_file)
|
||||
tombstone_data = self._get_file_content(tombstone_file)
|
||||
|
||||
for crash_id, paths in sorted(grouped.items()):
|
||||
parsed_sources: dict[str, dict] = {}
|
||||
source_records: dict[str, dict] = {}
|
||||
for source in ("text", "protobuf"):
|
||||
file_path = paths.get(source)
|
||||
if file_path is None:
|
||||
continue
|
||||
file_name = file_path.rsplit("/", 1)[-1]
|
||||
file_timestamp = self._get_file_modification_time(file_path)
|
||||
source_info = {
|
||||
"file_name": file_name,
|
||||
"file_timestamp": file_timestamp.isoformat(),
|
||||
"parsed": False,
|
||||
"error": None,
|
||||
"record": None,
|
||||
}
|
||||
try:
|
||||
data = self._get_file_content(file_path)
|
||||
if source == "protobuf":
|
||||
record = self.parse_protobuf_record(
|
||||
file_name, file_timestamp, data
|
||||
)
|
||||
else:
|
||||
record = self.parse_text_record(file_name, file_timestamp, data)
|
||||
source_info["parsed"] = True
|
||||
source_info["record"] = record
|
||||
source_records[source] = record
|
||||
except Exception as exc:
|
||||
source_info["error"] = str(exc)
|
||||
self.log.error(
|
||||
"Error parsing tombstone file %s: %s", file_path, exc
|
||||
try:
|
||||
if tombstone_file.endswith(".pb"):
|
||||
self.parse_protobuf(
|
||||
tombstone_filename, modification_time, tombstone_data
|
||||
)
|
||||
parsed_sources[source] = source_info
|
||||
|
||||
if not source_records:
|
||||
continue
|
||||
preferred = source_records.get("protobuf") or source_records["text"]
|
||||
canonical = dict(preferred)
|
||||
text_record = source_records.get("text")
|
||||
if text_record:
|
||||
for key, value in text_record.items():
|
||||
if canonical.get(key) in (None, "", [], {}):
|
||||
canonical[key] = value
|
||||
|
||||
differences = {}
|
||||
protobuf_record = source_records.get("protobuf")
|
||||
if text_record and protobuf_record:
|
||||
for key in text_record.keys() & protobuf_record.keys():
|
||||
if key in ("file_name", "file_timestamp"):
|
||||
continue
|
||||
if text_record[key] != protobuf_record[key]:
|
||||
differences[key] = {
|
||||
"text": text_record[key],
|
||||
"protobuf": protobuf_record[key],
|
||||
}
|
||||
canonical.update(
|
||||
{
|
||||
"crash_id": crash_id,
|
||||
"sources": parsed_sources,
|
||||
"differences": differences,
|
||||
}
|
||||
)
|
||||
self.results.append(canonical)
|
||||
else:
|
||||
self.parse(tombstone_filename, modification_time, tombstone_data)
|
||||
except ValueError as e:
|
||||
# Catch any exceptions raised during parsing or validation.
|
||||
self.log.error(f"Error parsing tombstone file {tombstone_file}: {e}")
|
||||
|
||||
self.log.info(
|
||||
"Extracted a total of %d tombstone files",
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
# 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 .connect_event import ConnectEvent
|
||||
from .dns_event import DnsEvent
|
||||
from .security_event import SecurityEvent
|
||||
|
||||
INTRUSION_LOGS_MODULES: list[type[MVTModule]] = [
|
||||
INTRUSION_LOGS_MODULES = [
|
||||
DnsEvent,
|
||||
ConnectEvent,
|
||||
SecurityEvent,
|
||||
|
||||
+4
-2
@@ -47,8 +47,10 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
"""Mobile Verification Toolkit.
|
||||
|
||||
The 'mvt-ios' and 'mvt-android' CLI commands are used to perform
|
||||
forensic analysis on IOS and Android devices.
|
||||
mvt-ios and mvt-android run the forensic analysis of an acquisition: each
|
||||
provides the check-* commands of its platform. This command hosts what
|
||||
belongs to neither platform; run it without a command to see the installed
|
||||
version and the list of what it offers.
|
||||
"""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["disable_version_check"] = disable_update_check
|
||||
|
||||
@@ -15,9 +15,7 @@ from typing import Iterable
|
||||
|
||||
import click
|
||||
|
||||
# This module is imported by every CLI at start-up, and by shell completion on
|
||||
# every keystroke, so it must stay cheap: nothing here may import the module
|
||||
# machinery (mvt.common.module_loader and what it pulls in).
|
||||
from .module_loader import CUSTOM_COMMAND_MODULE_PREFIX
|
||||
|
||||
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
|
||||
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
|
||||
@@ -26,9 +24,6 @@ NEUTRAL_CLI_PLUGIN_GROUP = "mvt.cli_plugins"
|
||||
MVT_CUSTOM_COMMANDS_ENV = "MVT_CUSTOM_COMMANDS"
|
||||
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
|
||||
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
|
||||
# Prefix of the import name given to a command file loaded from a path. Shared
|
||||
# with module_loader, which recognises such files when naming their loggers.
|
||||
CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_"
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
+16
-265
@@ -72,10 +72,6 @@ class Command:
|
||||
# down a password to decrypt a backup or flags which are need by some modules.
|
||||
self.module_options = module_options if module_options else {}
|
||||
|
||||
# This dictionary maps the modules which were replaced by a module
|
||||
# declaring `replaces` to the module which took their place.
|
||||
self.module_replacements: dict[type[MVTModule], type[MVTModule]] = {}
|
||||
|
||||
# This list will contain all executed modules.
|
||||
# We can use this to reference e.g. self.executed[0].results.
|
||||
self.executed: list[MVTModule] = []
|
||||
@@ -261,174 +257,7 @@ class Command:
|
||||
if module not in deduplicated:
|
||||
deduplicated.append(module)
|
||||
|
||||
available = self._apply_replacements(deduplicated)
|
||||
self._warn_about_slug_collisions(available)
|
||||
|
||||
return available
|
||||
|
||||
def _warn_about_slug_collisions(self, modules: list[type[MVTModule]]) -> None:
|
||||
"""Report modules writing their results to the same file.
|
||||
|
||||
Results are stored in a file named after the module slug, so two
|
||||
modules sharing one silently overwrite each other. Replacements are
|
||||
already resolved here, so a module deliberately taking over the slug
|
||||
of the module it replaces is not reported: the module it replaced is
|
||||
no longer part of the run.
|
||||
"""
|
||||
modules_by_slug: dict[str, type[MVTModule]] = {}
|
||||
for module in modules:
|
||||
slug = module.get_slug()
|
||||
first = modules_by_slug.setdefault(slug, module)
|
||||
if first is module:
|
||||
continue
|
||||
|
||||
self.log.warning(
|
||||
"Modules %s from %s and %s from %s both use the slug %s. If "
|
||||
"both run, whichever runs last overwrites the results of the "
|
||||
"other in %s.json.",
|
||||
first.__name__,
|
||||
get_module_origin(first).label,
|
||||
module.__name__,
|
||||
get_module_origin(module).label,
|
||||
slug,
|
||||
slug,
|
||||
)
|
||||
|
||||
def _declared_replacements(
|
||||
self, modules: list[type[MVTModule]]
|
||||
) -> dict[type[MVTModule], type[MVTModule]]:
|
||||
"""Return the replacements declared by the given modules."""
|
||||
replacements: dict[type[MVTModule], type[MVTModule]] = {}
|
||||
for module in modules:
|
||||
replaced = module.replaces
|
||||
if replaced is None or replaced is module:
|
||||
continue
|
||||
|
||||
if not module.enabled:
|
||||
# Replacing a module must not disable it, as a disabled
|
||||
# replacement never runs in its place.
|
||||
self.log.debug(
|
||||
"Module %s is disabled and does not replace module %s.",
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
if replaced not in modules:
|
||||
# A module can support several commands while the module it
|
||||
# replaces is only available in some of them.
|
||||
self.log.debug(
|
||||
"Module %s replaces module %s, which is not available "
|
||||
"for the %s command.",
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
self.name,
|
||||
)
|
||||
continue
|
||||
|
||||
if replaced in replacements:
|
||||
self.log.warning(
|
||||
"Modules %s and %s both replace module %s. Both of them "
|
||||
"will run, %s will not, and modules depending on %s will "
|
||||
"use the results of %s. Replacements which share the slug "
|
||||
"of %s overwrite each other's results file.",
|
||||
replacements[replaced].__name__,
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
replaced.__name__,
|
||||
replaced.__name__,
|
||||
replacements[replaced].__name__,
|
||||
replaced.__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
replacements[replaced] = module
|
||||
|
||||
return replacements
|
||||
|
||||
def _drop_replacement_cycles(
|
||||
self, replacements: dict[type[MVTModule], type[MVTModule]]
|
||||
) -> None:
|
||||
"""Undo the replacements between modules which replace each other."""
|
||||
cyclic: set[type[MVTModule]] = set()
|
||||
for replaced in replacements:
|
||||
walked: list[type[MVTModule]] = []
|
||||
module = replaced
|
||||
while module in replacements and module not in cyclic:
|
||||
if module in walked:
|
||||
cyclic.update(walked[walked.index(module) :])
|
||||
break
|
||||
walked.append(module)
|
||||
module = replacements[module]
|
||||
|
||||
if not cyclic:
|
||||
return
|
||||
|
||||
self.log.warning(
|
||||
"Modules %s replace each other in a cycle. None of them replaces "
|
||||
"anything and all of them will run.",
|
||||
", ".join(sorted(module.__name__ for module in cyclic)),
|
||||
)
|
||||
for module in cyclic:
|
||||
replacements.pop(module, None)
|
||||
|
||||
def _log_replacement(
|
||||
self,
|
||||
replaced: type[MVTModule],
|
||||
module: type[MVTModule],
|
||||
replacement: type[MVTModule],
|
||||
) -> None:
|
||||
"""Report an applied replacement, and any problem with it."""
|
||||
if not issubclass(module, replaced):
|
||||
self.log.warning(
|
||||
"Module %s replaces module %s but is not a subclass of it. "
|
||||
"Its results might not be compatible with what modules "
|
||||
"depending on %s expect.",
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
replaced.__name__,
|
||||
)
|
||||
|
||||
if replaced in module.dependencies:
|
||||
self.log.warning(
|
||||
"Module %s depends on module %s, which it also replaces. The "
|
||||
"dependency cannot be satisfied: a replacement has to produce "
|
||||
"that data itself.",
|
||||
module.__name__,
|
||||
replaced.__name__,
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Module %s from %s replaces module %s from %s.",
|
||||
replacement.__name__,
|
||||
get_module_origin(replacement).label,
|
||||
replaced.__name__,
|
||||
get_module_origin(replaced).label,
|
||||
)
|
||||
|
||||
def _apply_replacements(
|
||||
self, modules: list[type[MVTModule]]
|
||||
) -> list[type[MVTModule]]:
|
||||
"""Drop the modules superseded by a module declaring `replaces`."""
|
||||
declared = self._declared_replacements(modules)
|
||||
self._drop_replacement_cycles(declared)
|
||||
|
||||
replacements: dict[type[MVTModule], type[MVTModule]] = {}
|
||||
for replaced, module in declared.items():
|
||||
# Follow chains of replacements, so that a dependency on a
|
||||
# replaced module always resolves to a module which is part of
|
||||
# the run.
|
||||
replacement = module
|
||||
while replacement in declared:
|
||||
replacement = declared[replacement]
|
||||
|
||||
# Only the replacements which are applied are reported, so that
|
||||
# the record matches the modules which actually run.
|
||||
self._log_replacement(replaced, module, replacement)
|
||||
replacements[replaced] = replacement
|
||||
|
||||
self.module_replacements = replacements
|
||||
return [module for module in modules if module not in replacements]
|
||||
return deduplicated
|
||||
|
||||
def init(self) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -488,80 +317,6 @@ class Command:
|
||||
console.print("")
|
||||
console.print(panel)
|
||||
|
||||
def _module_dependencies(
|
||||
self, module: type[MVTModule]
|
||||
) -> list[tuple[type[MVTModule], type[MVTModule]]]:
|
||||
"""Return the (declared, resolved) dependencies of a module.
|
||||
|
||||
A dependency on a module which was replaced is resolved to the module
|
||||
which took its place. A module which replaces one of its own
|
||||
dependencies is not made to depend on itself, while a module which
|
||||
declares itself as a dependency is left alone and still fails the
|
||||
circular dependency check.
|
||||
"""
|
||||
dependencies = []
|
||||
for dependency in module.dependencies:
|
||||
resolved = self.module_replacements.get(dependency, dependency)
|
||||
if resolved is module and dependency is not module:
|
||||
continue
|
||||
dependencies.append((dependency, resolved))
|
||||
|
||||
return dependencies
|
||||
|
||||
@staticmethod
|
||||
def _dependency_name(
|
||||
declared: type[MVTModule], resolved: type[MVTModule]
|
||||
) -> str:
|
||||
"""Return how a dependency is named in the messages about it.
|
||||
|
||||
A dependency is named as the module which declared it wrote it, and,
|
||||
when that module was replaced, as the module which runs in its place.
|
||||
"""
|
||||
if declared is resolved:
|
||||
return declared.__name__
|
||||
|
||||
return f"{resolved.__name__} (replacing module {declared.__name__})"
|
||||
|
||||
def _selected_modules(
|
||||
self, modules: list[type[MVTModule]]
|
||||
) -> Optional[list[type[MVTModule]]]:
|
||||
"""Return the modules explicitly requested, or all the enabled ones.
|
||||
|
||||
Returns None when a module was requested by name and no module of
|
||||
that name can be run.
|
||||
"""
|
||||
if not self.module_name:
|
||||
return [module for module in modules if module.enabled]
|
||||
|
||||
selected = [
|
||||
module for module in modules if module.__name__ == self.module_name
|
||||
]
|
||||
|
||||
# A module replacing another one does not have to keep its name, so
|
||||
# the name of a replaced module selects its replacement.
|
||||
if not selected:
|
||||
for replaced, replacement in self.module_replacements.items():
|
||||
if replaced.__name__ != self.module_name or replacement in selected:
|
||||
continue
|
||||
self.log.info(
|
||||
"Module %s was replaced by module %s, which is run "
|
||||
"in its place.",
|
||||
replaced.__name__,
|
||||
replacement.__name__,
|
||||
)
|
||||
selected.append(replacement)
|
||||
|
||||
if not selected:
|
||||
self.log.warning(
|
||||
"No module named %s is available for the %s command. "
|
||||
"No modules will be run.",
|
||||
self.module_name,
|
||||
self.name,
|
||||
)
|
||||
return None
|
||||
|
||||
return selected
|
||||
|
||||
def _skipped_modules(
|
||||
self,
|
||||
required: list[type[MVTModule]],
|
||||
@@ -594,18 +349,15 @@ class Command:
|
||||
if module in skipped:
|
||||
continue
|
||||
|
||||
for declared, dependency in self._module_dependencies(module):
|
||||
for dependency in module.dependencies:
|
||||
if dependency not in module_indexes:
|
||||
# A replaced dependency always resolves to a module of
|
||||
# this command, so an unavailable one is always the
|
||||
# module class the author declared.
|
||||
skipped[module] = (module, declared)
|
||||
skipped[module] = (module, dependency)
|
||||
changed = True
|
||||
self.log.warning(
|
||||
"Module %s will be SKIPPED: it depends on module "
|
||||
"%s, which is not available in this command.%s",
|
||||
module.__name__,
|
||||
declared.__name__,
|
||||
dependency.__name__,
|
||||
remainder,
|
||||
)
|
||||
break
|
||||
@@ -620,7 +372,7 @@ class Command:
|
||||
"module %s, itself skipped for depending on "
|
||||
"unavailable module %s.%s",
|
||||
module.__name__,
|
||||
self._dependency_name(declared, dependency),
|
||||
dependency.__name__,
|
||||
missing.__name__,
|
||||
remainder,
|
||||
)
|
||||
@@ -631,7 +383,7 @@ class Command:
|
||||
"module %s, which depends on unavailable "
|
||||
"module %s.%s",
|
||||
module.__name__,
|
||||
self._dependency_name(declared, dependency),
|
||||
dependency.__name__,
|
||||
root.__name__,
|
||||
missing.__name__,
|
||||
remainder,
|
||||
@@ -645,9 +397,12 @@ class Command:
|
||||
modules = self._available_modules()
|
||||
module_indexes = {module: index for index, module in enumerate(modules)}
|
||||
|
||||
selected = self._selected_modules(modules)
|
||||
if selected is None:
|
||||
return None
|
||||
if self.module_name:
|
||||
selected = [
|
||||
module for module in modules if module.__name__ == self.module_name
|
||||
]
|
||||
else:
|
||||
selected = [module for module in modules if module.enabled]
|
||||
|
||||
required: set[type[MVTModule]] = set()
|
||||
pending = list(selected)
|
||||
@@ -656,7 +411,7 @@ class Command:
|
||||
if module in required:
|
||||
continue
|
||||
required.add(module)
|
||||
for _, dependency in self._module_dependencies(module):
|
||||
for dependency in module.dependencies:
|
||||
# Unavailable dependencies are reported by _skipped_modules().
|
||||
if dependency in module_indexes:
|
||||
pending.append(dependency)
|
||||
@@ -669,14 +424,13 @@ class Command:
|
||||
"Every selected module was skipped for an unavailable "
|
||||
"dependency. No modules will be run."
|
||||
)
|
||||
return None
|
||||
|
||||
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
|
||||
module: [] for module in runnable
|
||||
}
|
||||
indegree = {module: 0 for module in runnable}
|
||||
for module in runnable:
|
||||
for _, dependency in self._module_dependencies(module):
|
||||
for dependency in module.dependencies:
|
||||
if dependency not in indegree:
|
||||
continue
|
||||
dependents[dependency].append(module)
|
||||
@@ -732,12 +486,9 @@ class Command:
|
||||
module_options=self.module_options,
|
||||
log=module_logger,
|
||||
)
|
||||
# Dependencies are keyed by the module class they declare, even
|
||||
# when it was replaced, so that a module asking for the results
|
||||
# of a replaced module receives those of its replacement.
|
||||
m.dependency_modules = {
|
||||
dependency: executed_by_type[resolved]
|
||||
for dependency, resolved in self._module_dependencies(module)
|
||||
dependency: executed_by_type[dependency]
|
||||
for dependency in module.dependencies
|
||||
}
|
||||
|
||||
if self.iocs.total_ioc_count:
|
||||
|
||||
@@ -59,16 +59,13 @@ class MVTSettings(BaseSettings):
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> Tuple[PydanticBaseSettingsSource, ...]:
|
||||
yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH)
|
||||
sources: Tuple[PydanticBaseSettingsSource, ...] = (
|
||||
YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH),
|
||||
yaml_source,
|
||||
init_settings,
|
||||
)
|
||||
# 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
|
||||
# Always load env variables by default
|
||||
sources = (env_settings,) + sources
|
||||
return sources
|
||||
|
||||
def save_settings(
|
||||
@@ -95,7 +92,7 @@ class MVTSettings(BaseSettings):
|
||||
|
||||
Afterwards we load the settings again, this time including the env variables.
|
||||
"""
|
||||
# Construct the settings without env variables so they are not persisted.
|
||||
# Set invalid env prefix to avoid loading env variables.
|
||||
settings = cls(load_env=False)
|
||||
settings.save_settings()
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
|
||||
HELP_MSG_BACKUP_DESTINATION = (
|
||||
"Path to the folder where the decrypted backup should be stored"
|
||||
)
|
||||
HELP_MSG_DECRYPT_JOBS = "Number of files to decrypt concurrently"
|
||||
HELP_MSG_IOS_BACKUP_PASSWORD = (
|
||||
"Password to use to decrypt the backup (or, set the {MVT_IOS_BACKUP_PASSWORD} "
|
||||
"environment variable)"
|
||||
|
||||
@@ -46,11 +46,6 @@ class MVTModule:
|
||||
slug: Optional[str] = None
|
||||
dependencies: Sequence[type["MVTModule"]] = ()
|
||||
supported_commands: Sequence[tuple[str, str]] = ()
|
||||
# A custom module can name a module class it supersedes, usually a
|
||||
# built-in one. When both are available to a command, the named module is
|
||||
# dropped from the run and this module takes its place, including in the
|
||||
# dependencies of any other module.
|
||||
replaces: Optional[type["MVTModule"]] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -18,7 +18,6 @@ from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from .cli_plugins import CUSTOM_COMMAND_MODULE_PREFIX
|
||||
from .module import MVTModule
|
||||
from .version import MVT_VERSION
|
||||
|
||||
@@ -28,6 +27,8 @@ EXTERNAL_LOGGER_NAMESPACE = "mvt.ext"
|
||||
PLUGIN_PACKAGE_PREFIX = "mvt_plugin_"
|
||||
_ORIGIN_ATTRIBUTE = "_mvt_module_origin"
|
||||
_PATH_MODULE_PREFIX = "_mvt_custom_module_"
|
||||
# Shared with cli_plugins, which names a loaded command file this way.
|
||||
CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_"
|
||||
_LOADED_FILE_DIGEST = re.compile(r"_[0-9a-f]{16}$")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -401,14 +402,4 @@ def module_supports_command(
|
||||
)
|
||||
return False
|
||||
|
||||
pairs = {tuple(entry) for entry in supported_commands}
|
||||
if (platform, command) in pairs:
|
||||
return True
|
||||
|
||||
# A module which implements check_indicators() is re-checked by check-iocs
|
||||
# for its platform. It does not need to declare the check-iocs pair.
|
||||
return (
|
||||
command == "check-iocs"
|
||||
and platform in {entry[0] for entry in pairs if entry}
|
||||
and module_class.check_indicators is not MVTModule.check_indicators
|
||||
)
|
||||
return (platform, command) in {tuple(entry) for entry in supported_commands}
|
||||
@@ -14,6 +14,7 @@ from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Iterator, Union
|
||||
|
||||
from .log import MVTLogHandler
|
||||
from mvt.common.config import settings
|
||||
|
||||
|
||||
class CustomJSONEncoder(json.JSONEncoder):
|
||||
@@ -271,10 +272,6 @@ def set_verbose_logging(verbose: bool = False):
|
||||
|
||||
def exec_or_profile(module, globals, locals):
|
||||
"""Hook for profiling MVT modules"""
|
||||
# Imported here so that the CLI modules, which import this one at start-up,
|
||||
# do not load the settings (and pydantic) before a command runs.
|
||||
from .config import settings
|
||||
|
||||
if settings.PROFILE:
|
||||
cProfile.runctx(module, globals, locals)
|
||||
else:
|
||||
|
||||
@@ -3,6 +3,4 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
MVT_VERSION = version("mvt")
|
||||
MVT_VERSION = "2026.7.29"
|
||||
+16
-48
@@ -14,7 +14,10 @@ from mvt.common.cli_plugins import (
|
||||
load_cli_commands_option,
|
||||
register_cli_plugins,
|
||||
)
|
||||
from mvt.common.cmd_check_iocs import CmdCheckIOCS
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.options import MutuallyExclusiveOption
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
from mvt.common.utils import (
|
||||
generate_hashes_from_path,
|
||||
init_logging,
|
||||
@@ -24,7 +27,6 @@ from mvt.common.help import (
|
||||
HELP_MSG_VERSION,
|
||||
HELP_MSG_DECRYPT_BACKUP,
|
||||
HELP_MSG_BACKUP_DESTINATION,
|
||||
HELP_MSG_DECRYPT_JOBS,
|
||||
HELP_MSG_IOS_BACKUP_PASSWORD,
|
||||
HELP_MSG_BACKUP_KEYFILE,
|
||||
HELP_MSG_HASHES,
|
||||
@@ -45,16 +47,13 @@ from mvt.common.help import (
|
||||
HELP_MSG_DISABLE_UPDATE_CHECK,
|
||||
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
)
|
||||
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
|
||||
from mvt.common.password import prompt_password
|
||||
from .decrypt_config import (
|
||||
DEFAULT_DECRYPT_WORKERS,
|
||||
MAX_DECRYPT_WORKERS,
|
||||
)
|
||||
|
||||
# The commands import what they run only when they are invoked. This module is
|
||||
# imported at every start of mvt-ios, including by shell completion on every
|
||||
# keystroke, so importing it must do no more than build the command tree: the
|
||||
# forensic modules, the backup decryption and the update checks stay out of it.
|
||||
from .cmd_check_backup import CmdIOSCheckBackup
|
||||
from .cmd_check_fs import CmdIOSCheckFS
|
||||
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
|
||||
from .decrypt import DecryptBackup
|
||||
from .command_modules import IOS_CHECK_IOCS_MODULES
|
||||
|
||||
init_logging()
|
||||
log = logging.getLogger("mvt")
|
||||
@@ -80,8 +79,6 @@ def _get_verbose(ctx):
|
||||
|
||||
|
||||
def _load_custom_modules(load_module):
|
||||
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
|
||||
|
||||
try:
|
||||
return load_custom_modules(load_module)
|
||||
except CustomModuleLoadError as exc:
|
||||
@@ -109,9 +106,6 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
|
||||
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
|
||||
ctx.obj["verbose"] = verbose
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
from mvt.common.logo import logo
|
||||
|
||||
logo(
|
||||
disable_version_check=disable_update_check,
|
||||
disable_indicator_check=disable_indicator_update_check,
|
||||
@@ -133,13 +127,6 @@ def version():
|
||||
"decrypt-backup", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_DECRYPT_BACKUP
|
||||
)
|
||||
@click.option("--destination", "-d", required=True, help=HELP_MSG_BACKUP_DESTINATION)
|
||||
@click.option(
|
||||
"--jobs",
|
||||
type=click.IntRange(1, MAX_DECRYPT_WORKERS),
|
||||
default=DEFAULT_DECRYPT_WORKERS,
|
||||
show_default=True,
|
||||
help=HELP_MSG_DECRYPT_JOBS,
|
||||
)
|
||||
@click.option(
|
||||
"--password",
|
||||
"-p",
|
||||
@@ -158,10 +145,8 @@ def version():
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def decrypt_backup(ctx, destination, jobs, password, key_file, hashes, backup_path):
|
||||
from .decrypt import DecryptBackup
|
||||
|
||||
backup = DecryptBackup(backup_path, destination, max_workers=jobs)
|
||||
def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
|
||||
backup = DecryptBackup(backup_path, destination)
|
||||
|
||||
if key_file:
|
||||
if MVT_IOS_BACKUP_PASSWORD in os.environ:
|
||||
@@ -224,8 +209,6 @@ def decrypt_backup(ctx, destination, jobs, password, key_file, hashes, backup_pa
|
||||
)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
def extract_key(password, key_file, backup_path):
|
||||
from .decrypt import DecryptBackup
|
||||
|
||||
backup = DecryptBackup(backup_path)
|
||||
|
||||
if password:
|
||||
@@ -293,8 +276,6 @@ def check_backup(
|
||||
verbose,
|
||||
backup_path,
|
||||
):
|
||||
from .cmd_check_backup import CmdIOSCheckBackup
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
module_options = {"fast_mode": fast}
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
@@ -364,8 +345,6 @@ def check_fs(
|
||||
verbose,
|
||||
dump_path,
|
||||
):
|
||||
from .cmd_check_fs import CmdIOSCheckFS
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
module_options = {"fast_mode": fast}
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
@@ -434,8 +413,6 @@ def check_sysdiagnose(
|
||||
verbose,
|
||||
sysdiagnose_path,
|
||||
):
|
||||
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdIOSCheckSysdiagnose(
|
||||
@@ -449,14 +426,11 @@ def check_sysdiagnose(
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
|
||||
# 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 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\"),)."
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -493,10 +467,6 @@ def check_sysdiagnose(
|
||||
@click.argument("FOLDER", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
from mvt.common.cmd_check_iocs import CmdCheckIOCS
|
||||
|
||||
from .command_modules import IOS_CHECK_IOCS_MODULES
|
||||
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdCheckIOCS(
|
||||
target_path=folder,
|
||||
@@ -523,8 +493,6 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
|
||||
# ==============================================================================
|
||||
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
|
||||
def download_iocs():
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
|
||||
ioc_updates = IndicatorsUpdates()
|
||||
ioc_updates.update()
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ 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__)
|
||||
|
||||
|
||||
@@ -54,7 +52,6 @@ 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] = []
|
||||
|
||||
@@ -16,8 +16,7 @@ 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 + SYSDIAGNOSE_MODULES
|
||||
BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
)
|
||||
+74
-292
@@ -6,179 +6,18 @@
|
||||
import binascii
|
||||
import glob
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import os.path
|
||||
import plistlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from concurrent.futures import (
|
||||
ALL_COMPLETED,
|
||||
FIRST_COMPLETED,
|
||||
Future,
|
||||
ThreadPoolExecutor,
|
||||
wait,
|
||||
)
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from iphone_backup_decrypt import EncryptedBackup
|
||||
from iphone_backup_decrypt import google_iphone_dataprotection
|
||||
from iphone_backup_decrypt.utils import FilePlist
|
||||
|
||||
from .decrypt_config import DEFAULT_DECRYPT_WORKERS, MAX_DECRYPT_WORKERS
|
||||
from iOSbackup import iOSbackup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Import pbkdf2_hmac from the same source iphone_backup_decrypt uses internally,
|
||||
# so our key derivation is consistent with theirs.
|
||||
try:
|
||||
from fastpbkdf2 import pbkdf2_hmac
|
||||
except ImportError:
|
||||
import Crypto.Hash.SHA1
|
||||
import Crypto.Hash.SHA256
|
||||
import Crypto.Protocol.KDF
|
||||
|
||||
_HASH_FNS = {"sha1": Crypto.Hash.SHA1, "sha256": Crypto.Hash.SHA256}
|
||||
|
||||
def pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None):
|
||||
return Crypto.Protocol.KDF.PBKDF2(
|
||||
password, salt, dklen, iterations, hmac_hash_module=_HASH_FNS[hash_name]
|
||||
)
|
||||
|
||||
|
||||
class MVTEncryptedBackup(EncryptedBackup):
|
||||
"""Extends EncryptedBackup with derived key export/import.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, *, backup_directory, passphrase=None, derived_key=None):
|
||||
if passphrase:
|
||||
super().__init__(backup_directory=backup_directory, passphrase=passphrase)
|
||||
self._derived_key = None # Will be set after keybag unlock
|
||||
elif derived_key:
|
||||
self._init_without_passphrase(backup_directory, derived_key)
|
||||
else:
|
||||
raise ValueError("Either passphrase or derived_key must be provided")
|
||||
|
||||
def _init_without_passphrase(self, backup_directory, derived_key):
|
||||
"""Replicate parent __init__ state without requiring a passphrase."""
|
||||
self.decrypted = False
|
||||
self._backup_directory = os.path.expandvars(backup_directory)
|
||||
self._passphrase = None
|
||||
self._manifest_plist_path = os.path.join(
|
||||
self._backup_directory, "Manifest.plist"
|
||||
)
|
||||
self._manifest_plist = None
|
||||
self._manifest_db_path = os.path.join(self._backup_directory, "Manifest.db")
|
||||
self._keybag = None
|
||||
self._unlocked = False
|
||||
self._temporary_folder = tempfile.mkdtemp()
|
||||
self._temp_decrypted_manifest_db_path = os.path.join(
|
||||
self._temporary_folder, "Manifest.db"
|
||||
)
|
||||
self._temp_manifest_db_conn = None
|
||||
self._derived_key = derived_key # 32 raw bytes
|
||||
|
||||
def _read_and_unlock_keybag(self):
|
||||
"""Override to capture derived key on password unlock, or use
|
||||
a pre-derived key to skip PBKDF2."""
|
||||
if self._unlocked:
|
||||
return self._unlocked
|
||||
|
||||
with open(self._manifest_plist_path, "rb") as infile:
|
||||
self._manifest_plist = plistlib.load(infile)
|
||||
self._keybag = google_iphone_dataprotection.Keybag(
|
||||
self._manifest_plist["BackupKeyBag"]
|
||||
)
|
||||
|
||||
if self._derived_key:
|
||||
# Skip PBKDF2, unwrap class keys directly with pre-derived key
|
||||
self._unlocked = _unlock_keybag_with_derived_key(
|
||||
self._keybag, self._derived_key
|
||||
)
|
||||
else:
|
||||
# Normal path: full PBKDF2 derivation, capturing the intermediate key
|
||||
self._unlocked, self._derived_key = _unlock_keybag_and_capture_key(
|
||||
self._keybag, self._passphrase
|
||||
)
|
||||
self._passphrase = None
|
||||
|
||||
if not self._unlocked:
|
||||
raise ValueError("Failed to decrypt keys: incorrect passphrase?")
|
||||
return True
|
||||
|
||||
def get_decryption_key(self):
|
||||
"""Return derived key as hex string (64 chars / 32 bytes)."""
|
||||
if self._derived_key is None:
|
||||
raise ValueError("No derived key available")
|
||||
return self._derived_key.hex()
|
||||
|
||||
def extract_file_by_id(self, *, file_id, file_bplist, output_filename):
|
||||
"""Extract one manifest entry without loading the whole file into memory."""
|
||||
self._read_and_unlock_keybag()
|
||||
file_plist = FilePlist(file_bplist)
|
||||
|
||||
if file_plist.encryption_key is None:
|
||||
source_filename = os.path.join(
|
||||
self._backup_directory, file_id[:2], file_id
|
||||
)
|
||||
shutil.copy2(source_filename, output_filename)
|
||||
return
|
||||
|
||||
inner_key = self._keybag.unwrapKeyForClass(
|
||||
file_plist.protection_class, file_plist.encryption_key
|
||||
)
|
||||
self._decrypt_file_to_disk(
|
||||
file_id=file_id,
|
||||
key=inner_key,
|
||||
file_plist=file_plist,
|
||||
output_filepath=output_filename,
|
||||
)
|
||||
|
||||
|
||||
def _unlock_keybag_with_derived_key(keybag, passphrase_key):
|
||||
"""Unlock keybag class keys using a pre-derived passphrase_key,
|
||||
skipping the expensive PBKDF2 rounds."""
|
||||
WRAP_PASSPHRASE = 2
|
||||
for classkey in keybag.classKeys.values():
|
||||
if b"WPKY" not in classkey:
|
||||
continue
|
||||
if classkey[b"WRAP"] & WRAP_PASSPHRASE:
|
||||
k = google_iphone_dataprotection._AESUnwrap(
|
||||
passphrase_key, classkey[b"WPKY"]
|
||||
)
|
||||
if not k:
|
||||
return False
|
||||
classkey[b"KEY"] = k
|
||||
return True
|
||||
|
||||
|
||||
def _unlock_keybag_and_capture_key(keybag, passphrase):
|
||||
"""Run full PBKDF2 key derivation and AES unwrap, returning
|
||||
(success, passphrase_key) so the derived key can be exported."""
|
||||
passphrase_round1 = pbkdf2_hmac(
|
||||
"sha256", passphrase, keybag.attrs[b"DPSL"], keybag.attrs[b"DPIC"], 32
|
||||
)
|
||||
passphrase_key = pbkdf2_hmac(
|
||||
"sha1", passphrase_round1, keybag.attrs[b"SALT"], keybag.attrs[b"ITER"], 32
|
||||
)
|
||||
WRAP_PASSPHRASE = 2
|
||||
for classkey in keybag.classKeys.values():
|
||||
if b"WPKY" not in classkey:
|
||||
continue
|
||||
if classkey[b"WRAP"] & WRAP_PASSPHRASE:
|
||||
k = google_iphone_dataprotection._AESUnwrap(
|
||||
passphrase_key, classkey[b"WPKY"]
|
||||
)
|
||||
if not k:
|
||||
return False, None
|
||||
classkey[b"KEY"] = k
|
||||
return True, passphrase_key
|
||||
|
||||
|
||||
class DecryptBackup:
|
||||
"""This class provides functions to decrypt an encrypted iTunes backup
|
||||
@@ -187,68 +26,19 @@ class DecryptBackup:
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backup_path: str,
|
||||
dest_path: Optional[str] = None,
|
||||
max_workers: int = DEFAULT_DECRYPT_WORKERS,
|
||||
) -> None:
|
||||
def __init__(self, backup_path: str, dest_path: Optional[str] = None) -> None:
|
||||
"""Decrypts an encrypted iOS backup.
|
||||
:param backup_path: Path to the encrypted backup folder
|
||||
:param dest_path: Path to the folder where to store the decrypted backup
|
||||
"""
|
||||
self.backup_path = os.path.abspath(backup_path)
|
||||
self.dest_path = dest_path
|
||||
if not 1 <= max_workers <= MAX_DECRYPT_WORKERS:
|
||||
raise ValueError(f"max_workers must be between 1 and {MAX_DECRYPT_WORKERS}")
|
||||
self.max_workers = max_workers
|
||||
self._backup: Optional[MVTEncryptedBackup] = None
|
||||
self._decryption_key: Optional[str] = None
|
||||
self._backup = None
|
||||
self._decryption_key = None
|
||||
|
||||
def can_process(self) -> bool:
|
||||
return self._backup is not None
|
||||
|
||||
def _process_file(
|
||||
self,
|
||||
*,
|
||||
file_id: str,
|
||||
file_bplist: bytes,
|
||||
output_path: Path,
|
||||
relative_path: str,
|
||||
domain: str,
|
||||
) -> None:
|
||||
assert self._backup is not None
|
||||
self._backup.extract_file_by_id(
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_filename=str(output_path),
|
||||
)
|
||||
log.info(
|
||||
"Decrypted file %s [%s] to %s/%s",
|
||||
relative_path,
|
||||
domain,
|
||||
output_path.parent,
|
||||
file_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_files(
|
||||
pending: dict[Future[None], str], *, all_files: bool = False
|
||||
) -> None:
|
||||
if not pending:
|
||||
return
|
||||
|
||||
done, _ = wait(
|
||||
pending,
|
||||
return_when=ALL_COMPLETED if all_files else FIRST_COMPLETED,
|
||||
)
|
||||
for future in done:
|
||||
relative_path = pending.pop(future)
|
||||
try:
|
||||
future.result()
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt file %s: %s", relative_path, exc)
|
||||
|
||||
@staticmethod
|
||||
def is_encrypted(backup_path: str) -> bool:
|
||||
"""Query Manifest.db file to see if it's encrypted or not.
|
||||
@@ -268,6 +58,21 @@ class DecryptBackup:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _process_file(
|
||||
self, relative_path: str, domain: str, item, file_id: str, item_folder: str
|
||||
) -> None:
|
||||
assert self._backup is not None
|
||||
self._backup.getFileDecryptedCopy(
|
||||
manifestEntry=item, targetName=file_id, targetFolder=item_folder
|
||||
)
|
||||
log.info(
|
||||
"Decrypted file %s [%s] to %s/%s",
|
||||
relative_path,
|
||||
domain,
|
||||
item_folder,
|
||||
file_id,
|
||||
)
|
||||
|
||||
def process_backup(self) -> None:
|
||||
assert self._backup is not None
|
||||
assert self.dest_path is not None
|
||||
@@ -276,71 +81,53 @@ class DecryptBackup:
|
||||
os.makedirs(self.dest_path)
|
||||
|
||||
manifest_path = os.path.join(self.dest_path, "Manifest.db")
|
||||
# Extract a decrypted Manifest.db to the destination folder.
|
||||
self._backup.save_manifest_file(output_filename=manifest_path)
|
||||
# We extract a decrypted Manifest.db.
|
||||
self._backup.getManifestDB()
|
||||
# We store it to the destination folder.
|
||||
shutil.copy(self._backup.manifestDB, manifest_path)
|
||||
|
||||
# Iterate over all files in the backup and decrypt them,
|
||||
# preserving the XX/file_id directory structure that downstream
|
||||
# modules expect.
|
||||
backup_root = Path(self.backup_path).resolve()
|
||||
dest_root = Path(self.dest_path).resolve()
|
||||
pending: dict[Future[None], str] = {}
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
with self._backup.manifest_db_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT fileID, domain, relativePath, file FROM Files WHERE flags=1"
|
||||
)
|
||||
for file_id, domain, relative_path, file_bplist in cur:
|
||||
# This may be a partial backup. Skip files from the manifest
|
||||
# which do not exist locally.
|
||||
source_file_path = backup_root / file_id[:2] / file_id
|
||||
if not source_file_path.resolve().is_relative_to(backup_root):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(source_file_path):
|
||||
log.debug(
|
||||
"Skipping file %s. File not found in encrypted "
|
||||
"backup directory.",
|
||||
source_file_path,
|
||||
)
|
||||
continue
|
||||
pool = multiprocessing.Pool(multiprocessing.cpu_count())
|
||||
|
||||
output_path = dest_root / file_id[:2] / file_id
|
||||
if not output_path.resolve().is_relative_to(dest_root):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
for item in self._backup.getBackupFilesList():
|
||||
try:
|
||||
file_id = item["backupFile"]
|
||||
relative_path = item["relativePath"]
|
||||
domain = item["domain"]
|
||||
|
||||
if self.max_workers == 1:
|
||||
try:
|
||||
self._process_file(
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_path=output_path,
|
||||
relative_path=relative_path,
|
||||
domain=domain,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
"Failed to decrypt file %s: %s",
|
||||
relative_path,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
future = executor.submit(
|
||||
self._process_file,
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_path=output_path,
|
||||
relative_path=relative_path,
|
||||
domain=domain,
|
||||
# This may be a partial backup. Skip files from the manifest
|
||||
# which do not exist locally.
|
||||
source_file_path = os.path.join(self.backup_path, file_id[0:2], file_id)
|
||||
if not Path(source_file_path).resolve().is_relative_to(Path(self.backup_path).resolve()):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(source_file_path):
|
||||
log.debug(
|
||||
"Skipping file %s. File not found in encrypted backup directory.",
|
||||
source_file_path,
|
||||
)
|
||||
pending[future] = relative_path
|
||||
if len(pending) >= self.max_workers:
|
||||
self._wait_for_files(pending)
|
||||
continue
|
||||
|
||||
self._wait_for_files(pending, all_files=True)
|
||||
item_folder = os.path.join(self.dest_path, file_id[0:2]) # type: ignore[arg-type]
|
||||
if not Path(os.path.join(item_folder, file_id)).resolve().is_relative_to(Path(self.dest_path).resolve()):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(item_folder):
|
||||
os.makedirs(item_folder)
|
||||
|
||||
# iOSBackup getFileDecryptedCopy() claims to read a "file"
|
||||
# parameter but the code actually is reading the "manifest" key.
|
||||
# Add manifest plist to both keys to handle this.
|
||||
item["manifest"] = item["file"]
|
||||
|
||||
pool.apply_async(
|
||||
self._process_file,
|
||||
args=(relative_path, domain, item, file_id, item_folder),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt file %s: %s", relative_path, exc)
|
||||
|
||||
pool.close()
|
||||
pool.join()
|
||||
|
||||
# Copying over the root plist files as well.
|
||||
for file_name in os.listdir(self.backup_path):
|
||||
@@ -381,23 +168,20 @@ class DecryptBackup:
|
||||
return
|
||||
|
||||
try:
|
||||
self._backup = MVTEncryptedBackup(
|
||||
backup_directory=self.backup_path,
|
||||
passphrase=password,
|
||||
self._backup = iOSbackup(
|
||||
udid=os.path.basename(self.backup_path),
|
||||
cleartextpassword=password,
|
||||
backuproot=os.path.dirname(self.backup_path),
|
||||
)
|
||||
# Eagerly trigger keybag unlock so wrong-password errors
|
||||
# surface here rather than later during process_backup().
|
||||
self._backup.test_decryption()
|
||||
except Exception as exc:
|
||||
self._backup = None
|
||||
if (
|
||||
isinstance(exc, ValueError)
|
||||
and "passphrase" in str(exc).lower()
|
||||
isinstance(exc, KeyError)
|
||||
and len(exc.args) > 0
|
||||
and exc.args[0] == b"KEY"
|
||||
):
|
||||
log.critical("Failed to decrypt backup. Password is probably wrong.")
|
||||
elif (
|
||||
isinstance(exc, FileNotFoundError)
|
||||
and hasattr(exc, "filename")
|
||||
and os.path.basename(exc.filename) == "Manifest.plist"
|
||||
):
|
||||
log.critical(
|
||||
@@ -440,14 +224,12 @@ class DecryptBackup:
|
||||
|
||||
try:
|
||||
key_bytes_raw = binascii.unhexlify(key_bytes)
|
||||
self._backup = MVTEncryptedBackup(
|
||||
backup_directory=self.backup_path,
|
||||
derived_key=key_bytes_raw,
|
||||
self._backup = iOSbackup(
|
||||
udid=os.path.basename(self.backup_path),
|
||||
derivedkey=key_bytes_raw,
|
||||
backuproot=os.path.dirname(self.backup_path),
|
||||
)
|
||||
# Eagerly trigger keybag unlock so wrong-key errors surface here.
|
||||
self._backup.test_decryption()
|
||||
except Exception as exc:
|
||||
self._backup = None
|
||||
log.exception(exc)
|
||||
log.critical(
|
||||
"Failed to decrypt backup. Did you provide the correct key file?"
|
||||
@@ -458,7 +240,7 @@ class DecryptBackup:
|
||||
if not self._backup:
|
||||
return
|
||||
|
||||
self._decryption_key = self._backup.get_decryption_key()
|
||||
self._decryption_key = self._backup.getDecryptionKey()
|
||||
log.info(
|
||||
'Derived decryption key for backup at path %s is: "%s"',
|
||||
self.backup_path,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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/
|
||||
|
||||
"""Configuration shared by the iOS CLI and backup decryption implementation."""
|
||||
|
||||
DEFAULT_DECRYPT_WORKERS = 4
|
||||
MAX_DECRYPT_WORKERS = 32
|
||||
@@ -3,16 +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 .backup_info import BackupInfo
|
||||
from .configuration_profiles import ConfigurationProfiles
|
||||
from .manifest import Manifest
|
||||
from .profile_events import ProfileEvents
|
||||
|
||||
BACKUP_MODULES: list[type[MVTModule]] = [
|
||||
BackupInfo,
|
||||
ConfigurationProfiles,
|
||||
Manifest,
|
||||
ProfileEvents,
|
||||
]
|
||||
BACKUP_MODULES = [BackupInfo, ConfigurationProfiles, Manifest, ProfileEvents]
|
||||
@@ -3,8 +3,6 @@
|
||||
# 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 .analytics import Analytics
|
||||
from .analytics_ios_versions import AnalyticsIOSVersions
|
||||
from .cache_files import CacheFiles
|
||||
@@ -17,7 +15,7 @@ from .webkit_indexeddb import WebkitIndexedDB
|
||||
from .webkit_localstorage import WebkitLocalStorage
|
||||
from .webkit_safariviewservice import WebkitSafariViewService
|
||||
|
||||
FS_MODULES: list[type[MVTModule]] = [
|
||||
FS_MODULES = [
|
||||
CacheFiles,
|
||||
Filesystem,
|
||||
Netusage,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
# 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 .applications import Applications
|
||||
from .calendar import Calendar
|
||||
from .calls import Calls
|
||||
@@ -30,7 +28,7 @@ from .webkit_session_resource_log import WebkitSessionResourceLog
|
||||
from .whatsapp import Whatsapp
|
||||
from .whatsapp_contacts import WhatsappContacts
|
||||
|
||||
MIXED_MODULES: list[type[MVTModule]] = [
|
||||
MIXED_MODULES = [
|
||||
Calls,
|
||||
ChromeFavicon,
|
||||
ChromeHistory,
|
||||
|
||||
@@ -3,9 +3,4 @@
|
||||
# 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]
|
||||
@@ -1,228 +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 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)
|
||||
@@ -23,7 +23,7 @@ class TestDumpsysAccessibilityArtifact:
|
||||
assert len(da.results) == 4
|
||||
assert da.results[0]["package_name"] == "com.android.settings"
|
||||
assert (
|
||||
da.results[0]["component"]
|
||||
da.results[0]["service"]
|
||||
== "com.android.settings/com.samsung.android.settings.development.gpuwatch.GPUWatchInterceptor"
|
||||
)
|
||||
|
||||
@@ -37,9 +37,7 @@ class TestDumpsysAccessibilityArtifact:
|
||||
da.parse(data)
|
||||
assert len(da.results) == 1
|
||||
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
|
||||
assert da.results[0]["service"] == "com.malware.service.malwareservice"
|
||||
|
||||
def test_accessibility_service_alert(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
@@ -54,22 +52,6 @@ class TestDumpsysAccessibilityArtifact:
|
||||
assert da.alertstore.alerts[0].level == AlertLevel.MEDIUM
|
||||
assert da.alertstore.alerts[0].event == da.results[0]
|
||||
|
||||
def test_same_component_is_kept_for_each_user(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
da.parse(
|
||||
"""User state[attributes:{id=0
|
||||
installed services: {
|
||||
0 : com.example/.Service
|
||||
}
|
||||
User state[attributes:{id=10
|
||||
installed services: {
|
||||
0 : com.example/.Service
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
assert [result["user_id"] for result in da.results] == [0, 10]
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
file = get_artifact("android_data/dumpsys_accessibility.txt")
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import base64
|
||||
|
||||
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
|
||||
from mvt.android.modules.bugreport.dumpsys_adb_state import DumpsysADBState
|
||||
from mvt.common.alerts import AlertLevel
|
||||
@@ -13,17 +11,6 @@ from ..utils import get_artifact
|
||||
|
||||
|
||||
class TestDumpsysADBArtifact:
|
||||
def test_parsing_binary_xml_recovers_key(self):
|
||||
public_key = base64.b64encode(bytes(range(256)))
|
||||
keystore = DumpsysADBArtifact().parse_binary_xml(
|
||||
b"ABX\x00binary-key=" + public_key + b" user@host\x00lastConnection"
|
||||
)
|
||||
|
||||
assert len(keystore) == 1
|
||||
assert keystore[0]["key"] == public_key.decode()
|
||||
assert keystore[0]["user"] == "user@host"
|
||||
assert keystore[0]["last_connected"] is None
|
||||
|
||||
def test_parsing(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
file = get_artifact("android_data/dumpsys_adb.txt")
|
||||
|
||||
@@ -25,37 +25,10 @@ class TestDumpsysAppopsArtifact:
|
||||
assert da.results[0]["uid"] == "0"
|
||||
assert len(da.results[0]["permissions"]) == 1
|
||||
assert da.results[0]["permissions"][0]["name"] == "MANAGE_IPSEC_TUNNELS"
|
||||
assert da.results[0]["permissions"][0]["mode"] == "allow"
|
||||
assert da.results[0]["permissions"][0]["access"] == "allow"
|
||||
assert da.results[6]["package_name"] == "com.sec.factory.camera"
|
||||
assert len(da.results[6]["permissions"][1]["entries"]) == 1
|
||||
assert len(da.results[11]["permissions"]) == 4
|
||||
wake_lock = next(
|
||||
permission
|
||||
for permission in da.results[11]["permissions"]
|
||||
if permission["name"] == "WAKE_LOCK"
|
||||
)
|
||||
assert wake_lock["entries"][0]["duration"] == "+126ms"
|
||||
|
||||
def test_running_and_attribution_are_retained(self):
|
||||
da = DumpsysAppopsArtifact()
|
||||
da.parse(
|
||||
""" Uid 0:
|
||||
state=cch
|
||||
Package com.example:
|
||||
CAMERA (allow):
|
||||
camera=[
|
||||
Access: [fg-s] 2025-01-01 00:00:00.000 (-1s) duration=+2ms
|
||||
]
|
||||
RECORD_AUDIO (allow):
|
||||
Running start at: +3s
|
||||
"""
|
||||
)
|
||||
|
||||
camera = da.results[0]["permissions"][0]["entries"][0]
|
||||
running = da.results[0]["permissions"][1]["entries"][0]
|
||||
assert camera["attribution"] == "camera"
|
||||
assert running["event"] == "running"
|
||||
assert running["relative_time"] == "+3s"
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
da = DumpsysAppopsArtifact()
|
||||
|
||||
@@ -57,19 +57,18 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
assert uninstall_alert.message == (
|
||||
"Detected uninstall of package com.example.removed (vers 0)"
|
||||
)
|
||||
assert uninstall_alert.event_time == "2022-08-16 15:56:39"
|
||||
assert uninstall_alert.event_time == "2022-08-16"
|
||||
assert uninstall_alert.event["package_name"] == "com.example.removed"
|
||||
assert uninstall_alert.event["version_code"] == 0
|
||||
assert uninstall_alert.event["action"] == "uninstall"
|
||||
assert uninstall_alert.event["vers"] == "0"
|
||||
|
||||
assert downgrade_alert.level == AlertLevel.MEDIUM
|
||||
assert downgrade_alert.message == (
|
||||
"Detected downgrade of package com.example.app from vers 10 to vers 9"
|
||||
)
|
||||
assert downgrade_alert.event_time == "2022-08-17 15:56:39"
|
||||
assert downgrade_alert.event_time == "2022-08-17"
|
||||
assert downgrade_alert.event["package_name"] == "com.example.app"
|
||||
assert downgrade_alert.event["action"] == "downgrade"
|
||||
assert downgrade_alert.event["previous_version_code"] == 10
|
||||
assert downgrade_alert.event["previous_vers"] == "10"
|
||||
|
||||
def test_newest_first_update_is_not_reported_as_downgrade(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
@@ -108,19 +107,7 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
assert downgrade_alert.event_time == "2026-01-10"
|
||||
assert downgrade_alert.event["package_name"] == "com.example.app"
|
||||
assert downgrade_alert.event["action"] == "downgrade"
|
||||
assert downgrade_alert.event["previous_version_code"] == 102
|
||||
|
||||
def test_duplicate_updates_retain_occurrence_count(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
""" Daily from 2026-01-10-01-02-03 to 2026-01-11-04-05-06:
|
||||
Update com.example.app vers=12
|
||||
Update com.example.app vers=12
|
||||
"""
|
||||
)
|
||||
|
||||
assert dba.results[0]["occurrences"] == 2
|
||||
assert dba.results[0]["period_start"] == "2026-01-10 01:02:03"
|
||||
assert downgrade_alert.event["previous_vers"] == "102"
|
||||
|
||||
def test_reinstall_after_uninstall_is_not_reported_as_downgrade(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
|
||||
@@ -54,7 +54,6 @@ class TestDumpsysBatteryHistoryArtifact:
|
||||
assert len(dba.results) == 2
|
||||
assert dba.results[0] == {
|
||||
"time_elapsed": "07-15 20:27:39.431",
|
||||
"timestamp": "1900-07-15 20:27:39.431000",
|
||||
"event": "start_job",
|
||||
"uid": "u0a123",
|
||||
"package_name": "com.example",
|
||||
@@ -62,23 +61,3 @@ class TestDumpsysBatteryHistoryArtifact:
|
||||
}
|
||||
assert dba.results[1]["event"] == "end_job"
|
||||
assert dba.results[1]["uid"] == "u0a123"
|
||||
|
||||
def test_wake_lock_without_component_is_retained(self):
|
||||
dba = DumpsysBatteryHistoryArtifact()
|
||||
dba.parse(
|
||||
"Battery History:\n"
|
||||
" 0 (2) 100 RESET:TIME: 2025-09-05-01-04-52-139\n"
|
||||
' +1s (2) 100 +running +wake_lock=1000:"*alarm*:TIME_TICK"\n'
|
||||
' +2s (2) 100 +running +wake_lock=u0a1:"*walarm*:com.whatsapp.MessageHandler.LOGOUT_ACTION"\n'
|
||||
"\n"
|
||||
)
|
||||
|
||||
assert [record["event"] for record in dba.results] == ["wake", "wake"]
|
||||
assert dba.results[0]["package_name"] is None
|
||||
assert dba.results[1]["package_name"] == "com.whatsapp"
|
||||
|
||||
def test_decorated_sync_job_uses_component_package(self):
|
||||
dba = DumpsysBatteryHistoryArtifact()
|
||||
dba.parse('+1s (2) 100 +job=u0a1:"@SyncManager@gmail-ls/com.google:android"\n')
|
||||
|
||||
assert dba.results[0]["package_name"] == "com.google"
|
||||
@@ -53,32 +53,9 @@ Connection pool for /data/user/0/com.example/databases/current.db:
|
||||
|
||||
assert dbi.results == [
|
||||
{
|
||||
"timestamp": "07-15 20:27:39.431",
|
||||
"pid": None,
|
||||
"isodate": "07-15 20:27:39.431",
|
||||
"action": "executeForCursorWindow",
|
||||
"duration_ms": 1,
|
||||
"status": "succeeded",
|
||||
"sql": "SELECT 1",
|
||||
"path": "/data/user/0/com.example/databases/current.db",
|
||||
"pool_path": "/data/user/0/com.example/databases/current.db",
|
||||
"connection_number": None,
|
||||
"is_primary": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_parses_operations_from_multiple_connections(self):
|
||||
dbi = DumpsysDBInfoArtifact()
|
||||
dbi.parse(
|
||||
"""Connection pool for /data/example.db:
|
||||
Connection #0:
|
||||
isPrimaryConnection: true
|
||||
Most recently executed operations:
|
||||
0: [2025-01-01 00:00:00.000] execute took 1ms - succeeded, sql="SELECT 1", path=/data/example.db
|
||||
Connection #1:
|
||||
isPrimaryConnection: false
|
||||
Most recently executed operations:
|
||||
0: [2025-01-01 00:00:01.000] execute took 2ms - succeeded, sql="SELECT 2", path=/data/example.db
|
||||
"""
|
||||
)
|
||||
|
||||
assert [record["connection_number"] for record in dbi.results] == [0, 1]
|
||||
@@ -21,16 +21,12 @@ class TestDumpsysPackageActivitiesArtifact:
|
||||
|
||||
assert len(dpa.results) == 0
|
||||
dpa.parse(data)
|
||||
assert len(dpa.results) == 10
|
||||
assert dpa.results[0]["package_name"] == "com.samsung.android.messaging"
|
||||
assert len(dpa.results) == 4
|
||||
assert dpa.results[0]["package_name"] == "com.samsung.android.app.social"
|
||||
assert (
|
||||
dpa.results[0]["component"]
|
||||
== "com.samsung.android.messaging/.ui.RcsTransferContent"
|
||||
dpa.results[0]["activity"]
|
||||
== "com.samsung.android.app.social/.feed.FeedsActivity"
|
||||
)
|
||||
assert {result["resolver_type"] for result in dpa.results} == {
|
||||
"full_mime_type",
|
||||
"non_data_action",
|
||||
}
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
dpa = DumpsysPackageActivitiesArtifact()
|
||||
@@ -45,4 +41,4 @@ class TestDumpsysPackageActivitiesArtifact:
|
||||
dpa.indicators = ind
|
||||
assert len(dpa.alertstore.alerts) == 0
|
||||
dpa.check_indicators()
|
||||
assert len(dpa.alertstore.alerts) == 2
|
||||
assert len(dpa.alertstore.alerts) == 1
|
||||
@@ -25,11 +25,7 @@ class TestDumpsysPackagesArtifact:
|
||||
== "com.samsung.android.provider.filterprovider"
|
||||
)
|
||||
assert dpa.results[0]["version_name"] == "5.0.07"
|
||||
assert dpa.results[0]["version_code"] == 500700000
|
||||
assert dpa.results[0]["min_sdk"] == 28
|
||||
assert dpa.results[0]["target_sdk"] == 28
|
||||
assert dpa.results[0]["package_type"] == "active"
|
||||
assert dpa.results[0]["users"][0]["user_id"] == 0
|
||||
assert dpa.results[0]["first_install_time"] == "2008-12-31 16:00:00"
|
||||
assert dpa.results[0]["system"] is True
|
||||
|
||||
def test_parsing_system_flag(self):
|
||||
@@ -64,27 +60,42 @@ class TestDumpsysPackagesArtifact:
|
||||
dpa.check_indicators()
|
||||
assert len(dpa.alertstore.alerts) == 1
|
||||
|
||||
def test_hidden_packages_and_per_user_state(self):
|
||||
dpa = DumpsysPackagesArtifact()
|
||||
dpa.parse(
|
||||
"""Packages:
|
||||
Package [com.example.active]:
|
||||
appId=10001
|
||||
versionCode=12 minSdk=29 targetSdk=35
|
||||
User 0: installed=true hidden=false
|
||||
firstInstallTime=2025-01-01 01:02:03
|
||||
User 10: installed=false hidden=true
|
||||
firstInstallTime=2025-01-02 01:02:03
|
||||
Hidden system packages:
|
||||
Package [com.example.hidden]:
|
||||
appId=10002
|
||||
installerPackageName=null
|
||||
def test_per_user_fields_use_primary_user(self):
|
||||
details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
|
||||
""" User 0: installed=true
|
||||
firstInstallTime=2024-01-10 09:19:39
|
||||
runtime permissions:
|
||||
android.permission.CAMERA: granted=true
|
||||
User 95: installed=false
|
||||
firstInstallTime=1970-01-01 01:00:00
|
||||
runtime permissions:
|
||||
android.permission.CAMERA: granted=false
|
||||
android.permission.RECORD_AUDIO: granted=false
|
||||
"""
|
||||
)
|
||||
|
||||
assert [record["package_type"] for record in dpa.results] == [
|
||||
"active",
|
||||
"hidden_system",
|
||||
assert details["first_install_time"] == "2024-01-10 09:19:39"
|
||||
runtime_permissions = [
|
||||
permission
|
||||
for permission in details["permissions"]
|
||||
if permission["type"] == "runtime"
|
||||
]
|
||||
assert len(dpa.results[0]["users"]) == 2
|
||||
assert dpa.results[1]["installer"] is None
|
||||
assert runtime_permissions == [
|
||||
{
|
||||
"name": "android.permission.CAMERA",
|
||||
"granted": True,
|
||||
"type": "runtime",
|
||||
}
|
||||
]
|
||||
|
||||
def test_per_user_fields_fall_back_when_user_zero_is_missing(self):
|
||||
details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
|
||||
""" User 10: installed=true
|
||||
firstInstallTime=2024-02-10 09:19:39
|
||||
runtime permissions:
|
||||
android.permission.CAMERA: granted=true
|
||||
"""
|
||||
)
|
||||
|
||||
assert details["first_install_time"] == "2024-02-10 09:19:39"
|
||||
assert details["permissions"][-1]["name"] == "android.permission.CAMERA"
|
||||
@@ -22,9 +22,6 @@ class TestDumpsysPlatformCompatArtifact:
|
||||
assert len(dbi.results) == 2
|
||||
assert dbi.results[0]["package_name"] == "org.torproject.torbrowser"
|
||||
assert dbi.results[1]["package_name"] == "org.article19.circulo.next"
|
||||
assert dbi.results[0]["change_id"] == 168419799
|
||||
assert dbi.results[0]["change_name"] == "DOWNSCALED"
|
||||
assert dbi.results[0]["override_value"] is False
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
dbi = DumpsysPlatformCompatArtifact()
|
||||
|
||||
@@ -19,14 +19,17 @@ class TestDumpsysReceiversArtifact:
|
||||
|
||||
assert len(dr.results) == 0
|
||||
dr.parse(data)
|
||||
assert len(dr.results) == 9
|
||||
assert dr.results[0]["resolver_type"] == "full_mime_type"
|
||||
storage_manager = next(
|
||||
result
|
||||
for result in dr.results
|
||||
if result["key"] == "com.android.storagemanager.automatic.SHOW_NOTIFICATION"
|
||||
assert len(dr.results) == 4
|
||||
assert (
|
||||
list(dr.results.keys())[0]
|
||||
== "com.android.storagemanager.automatic.SHOW_NOTIFICATION"
|
||||
)
|
||||
assert (
|
||||
dr.results["com.android.storagemanager.automatic.SHOW_NOTIFICATION"][0][
|
||||
"package_name"
|
||||
]
|
||||
== "com.android.storagemanager"
|
||||
)
|
||||
assert storage_manager["package_name"] == "com.android.storagemanager"
|
||||
|
||||
def test_parsing_misindented_action(self):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
@@ -41,8 +44,12 @@ Receiver Resolver Table:
|
||||
|
||||
dr.parse(data)
|
||||
|
||||
assert dr.results[1]["key"] == "android.intent.action.MY_PACKAGE_REPLACED"
|
||||
assert dr.results[1]["package_name"] == "com.psycatgames.nhiegame"
|
||||
assert (
|
||||
dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][
|
||||
"package_name"
|
||||
]
|
||||
== "com.psycatgames.nhiegame"
|
||||
)
|
||||
|
||||
def test_parsing_misindented_first_action(self):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
@@ -55,8 +62,12 @@ Receiver Resolver Table:
|
||||
|
||||
dr.parse(data)
|
||||
|
||||
assert dr.results[0]["key"] == "android.intent.action.MY_PACKAGE_REPLACED"
|
||||
assert dr.results[0]["package_name"] == "com.psycatgames.nhiegame"
|
||||
assert (
|
||||
dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][
|
||||
"package_name"
|
||||
]
|
||||
== "com.psycatgames.nhiegame"
|
||||
)
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
|
||||
@@ -39,29 +39,3 @@ class TestGetPropArtifact:
|
||||
assert len(gp.alertstore.alerts) == 0
|
||||
gp.check_indicators()
|
||||
assert len(gp.alertstore.alerts) == 1
|
||||
|
||||
def test_empty_values_and_invalid_lines(self):
|
||||
gp = GetProp()
|
||||
gp.parse("[empty]: []\n[valid]: [value]\n0\n[broken]: [value")
|
||||
|
||||
assert gp.results == [
|
||||
{"name": "empty", "value": ""},
|
||||
{"name": "valid", "value": "value"},
|
||||
]
|
||||
|
||||
def test_multiline_value(self):
|
||||
gp = GetProp()
|
||||
gp.parse(
|
||||
"[persist.sys.boot.reason.history]: ["
|
||||
"reboot,ota,1697044974\n"
|
||||
"reboot,watchdog,1696958574]\n"
|
||||
"[ro.build.version.sdk]: [35]\n"
|
||||
)
|
||||
|
||||
assert gp.results == [
|
||||
{
|
||||
"name": "persist.sys.boot.reason.history",
|
||||
"value": "reboot,ota,1697044974\nreboot,watchdog,1696958574",
|
||||
},
|
||||
{"name": "ro.build.version.sdk", "value": "35"},
|
||||
]
|
||||
@@ -20,7 +20,7 @@ class TestProcessesArtifact:
|
||||
assert len(p.results) == 0
|
||||
p.parse(data)
|
||||
assert len(p.results) == 17
|
||||
assert p.results[0]["command"] == "init"
|
||||
assert p.results[0]["proc_name"] == "init"
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
p = Processes()
|
||||
@@ -36,14 +36,3 @@ class TestProcessesArtifact:
|
||||
assert len(p.alertstore.alerts) == 0
|
||||
p.check_indicators()
|
||||
assert len(p.alertstore.alerts) == 1
|
||||
|
||||
def test_bugreport_thread_columns(self):
|
||||
p = Processes()
|
||||
p.parse(
|
||||
"LABEL USER PID TID PPID VSZ RSS WCHAN ADDR S PRI NI RTPRIO SCH PCY TIME CMD\n"
|
||||
"u:r:init:s0 root 1 2 0 100 20 0 0 S 19 0 - 0 fg 00:00:01 init\n"
|
||||
)
|
||||
|
||||
assert p.results[0]["label"] == "u:r:init:s0"
|
||||
assert p.results[0]["tid"] == 2
|
||||
assert p.results[0]["command"] == "init"
|
||||
@@ -1,170 +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 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"
|
||||
@@ -1,54 +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/
|
||||
"""A `Caused by:` line must not discard the whole text tombstone.
|
||||
|
||||
Keys are matched as bare prefixes, so `Caused by: …` — an ordinary line inside
|
||||
an abort message — reached the `Cause` key, failed the key comparison and
|
||||
raised, which `Tombstones.run()` logged while dropping the entire crash record.
|
||||
Seen on a 1.6 MB tombstone whose protobuf twin was zero bytes: the crash then
|
||||
had no representation at all.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact
|
||||
|
||||
TOMBSTONE = b"""\
|
||||
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
|
||||
Build fingerprint: 'Xiaomi/vili_eea/vili:13/TKQ1.220829.002/V14.0.10.0:user/release-keys'
|
||||
Revision: '0'
|
||||
ABI: 'arm64'
|
||||
Timestamp: 2023-08-24 14:54:47.999124034+0300
|
||||
Process uptime: 12199s
|
||||
Cmdline: com.example.game
|
||||
pid: 8044, tid: 26222, name: UnityMain >>> com.example.game <<<
|
||||
uid: 10235
|
||||
signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
|
||||
Abort message: 'No pending exception expected: java.lang.SecurityException: listen
|
||||
at void android.os.Parcel.readException() (Parcel.java:2920)
|
||||
Caused by: android.os.RemoteException: Remote stack trace:
|
||||
\tat com.android.server.TelephonyRegistry.listen(TelephonyRegistry.java:1096)
|
||||
"""
|
||||
|
||||
WITH_CAUSE = TOMBSTONE + b"Cause: null pointer dereference\n"
|
||||
|
||||
|
||||
class TestTombstoneCausedBy:
|
||||
def _parse(self, content):
|
||||
artifact = TombstoneCrashArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse("tombstone_23", datetime.datetime(2023, 8, 24), content)
|
||||
return artifact.results
|
||||
|
||||
def test_caused_by_line_does_not_discard_the_tombstone(self):
|
||||
results = self._parse(TOMBSTONE)
|
||||
assert len(results) == 1
|
||||
assert results[0]["pid"] == 8044
|
||||
assert results[0]["process_name"] == "UnityMain"
|
||||
assert results[0]["uid"] == 10235
|
||||
|
||||
def test_the_real_cause_key_is_still_parsed(self):
|
||||
results = self._parse(WITH_CAUSE)
|
||||
assert results[0]["cause"] == "null pointer dereference"
|
||||
@@ -128,5 +128,8 @@ class TestTombstoneCrashArtifact:
|
||||
assert tombstone_result.get("pid") == 25541
|
||||
assert tombstone_result.get("process_name") == "mtk.ape.decoder"
|
||||
|
||||
# Tombstones include an explicit offset, so normalize them to UTC.
|
||||
assert tombstone_result.get("timestamp") == "2023-04-12 10:32:40.518290"
|
||||
# With Android logs we want to keep timestamps as device local time for consistency.
|
||||
# We often don't know the time offset for a log entry and so can't convert everything to UTC.
|
||||
# MVT should output the local time only:
|
||||
# So original 2023-04-12 12:32:40.518290770+0200 -> 2023-04-12 12:32:40.000000
|
||||
assert tombstone_result.get("timestamp") == "2023-04-12 12:32:40.518290"
|
||||
@@ -1,39 +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/
|
||||
"""An encrypted backup.ab must not take the whole check-androidqf run with it.
|
||||
|
||||
`CmdAndroidCheckBackup.from_ab()` already raises `InvalidAndroidBackup` instead
|
||||
of exiting when it runs as a sub-command (`check-androidqf` catches that and
|
||||
skips the backup modules), for a wrong file format and for a parse error. The
|
||||
password branches used to call `sys.exit(1)` unconditionally, which ends the
|
||||
parent run inside `finish()` — before the intrusion-logs command and before the
|
||||
timeline, alerts, urls, info and run-manifest are written.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup, InvalidAndroidBackup
|
||||
|
||||
ENCRYPTED_AB_HEADER = b"ANDROID BACKUP\n5\n0\nAES-256\n" + b"\x00" * 64
|
||||
|
||||
|
||||
class TestCheckBackupOptionalFailure:
|
||||
def _cmd(self, tmp_path, sub_command):
|
||||
return CmdAndroidCheckBackup(
|
||||
target_path=None,
|
||||
results_path=str(tmp_path),
|
||||
module_options={"interactive": False},
|
||||
sub_command=sub_command,
|
||||
)
|
||||
|
||||
def test_missing_password_raises_when_nested(self, tmp_path):
|
||||
cmd = self._cmd(tmp_path, sub_command=True)
|
||||
with pytest.raises(InvalidAndroidBackup):
|
||||
cmd.from_ab(ENCRYPTED_AB_HEADER)
|
||||
|
||||
def test_missing_password_still_exits_on_its_own_command(self, tmp_path):
|
||||
cmd = self._cmd(tmp_path, sub_command=False)
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.from_ab(ENCRYPTED_AB_HEADER)
|
||||
@@ -13,19 +13,6 @@ from ..utils import get_android_androidqf, list_files
|
||||
|
||||
|
||||
class TestAndroidqfMountsArtifact:
|
||||
def test_parse_proc_mountinfo(self):
|
||||
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
|
||||
|
||||
results = MountsArtifact.parse_mountinfo(
|
||||
"41 40 254:13 / / ro,relatime shared:1 - erofs /dev/block/dm-13 ro,seclabel\n",
|
||||
123,
|
||||
)
|
||||
|
||||
assert results[0]["mount_id"] == 41
|
||||
assert results[0]["mount_point"] == "/"
|
||||
assert results[0]["filesystem_type"] == "erofs"
|
||||
assert results[0]["process_ids"] == [123]
|
||||
|
||||
def test_parse_mounts_token_checks(self):
|
||||
"""
|
||||
Test the artifact-level `parse` method using tolerant token checks.
|
||||
|
||||
@@ -19,13 +19,7 @@ class TestSettingsModule:
|
||||
parent_path = Path(data_path).absolute().parent.as_posix()
|
||||
m.from_dir(parent_path, files)
|
||||
run_module(m)
|
||||
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.results) == 1
|
||||
assert "random" in m.results.keys()
|
||||
assert len(m.alertstore.alerts) == 1
|
||||
assert "samsung_errorlog_agree" in m.alertstore.alerts[0].message
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
@@ -55,11 +54,10 @@ class TestBugreportAnalysis:
|
||||
== "com.samsung.android.provider.filterprovider"
|
||||
)
|
||||
assert m.results[1]["package_name"] == "com.instagram.android"
|
||||
assert m.results[0]["installer"] is None
|
||||
assert m.results[0]["installer"] == ""
|
||||
assert m.results[1]["installer"] == "com.android.vending"
|
||||
assert len(m.results[0]["permissions"]) == 4
|
||||
assert len(m.results[1]["permissions"]) == 20
|
||||
assert len(m.results[1]["users"][0]["permissions"]) == 19
|
||||
assert len(m.results[1]["permissions"]) == 32
|
||||
|
||||
def test_getprop_module(self):
|
||||
m = self.launch_bug_report_module(DumpsysGetProp)
|
||||
@@ -68,53 +66,29 @@ class TestBugreportAnalysis:
|
||||
def test_receivers_match_exact_package_name(self, indicators_factory):
|
||||
intent = "android.intent.action.PHONE_STATE"
|
||||
false_positive = {
|
||||
"resolver_type": "non_data_action",
|
||||
"key": intent,
|
||||
"package_name": "com.android.phone",
|
||||
"component": (
|
||||
"receiver": (
|
||||
"com.android.phone/"
|
||||
"com.android.services.telephony.sip.SipIncomingCallReceiver"
|
||||
),
|
||||
"filter_count": 1,
|
||||
}
|
||||
malicious_receiver = {
|
||||
"resolver_type": "non_data_action",
|
||||
"key": intent,
|
||||
"package_name": "com.android.services",
|
||||
"component": "com.android.services/com.example.SomeReceiver",
|
||||
"filter_count": 1,
|
||||
"receiver": "com.android.services/com.example.SomeReceiver",
|
||||
}
|
||||
module = DumpsysReceivers(results=[false_positive, malicious_receiver])
|
||||
module = DumpsysReceivers(
|
||||
results={intent: [false_positive, malicious_receiver]}
|
||||
)
|
||||
module.indicators = indicators_factory(app_ids=["com.android.services"])
|
||||
|
||||
module.check_indicators()
|
||||
|
||||
assert len(module.alertstore.alerts) == 1
|
||||
alert = module.alertstore.alerts[0]
|
||||
assert alert.event == malicious_receiver
|
||||
assert alert.event == {intent: 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
|
||||
assert m.results[1]["pid"] == 3559
|
||||
assert m.results[0]["sources"]["text"]["parsed"] is True
|
||||
@@ -264,53 +264,5 @@ 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
|
||||
@@ -15,7 +15,6 @@ from mvt.common.cmd_check_iocs import CmdCheckIOCS
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES
|
||||
from mvt.ios.modules.backup.manifest import Manifest
|
||||
|
||||
# Keep the banner of the group callback from checking for updates online.
|
||||
OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"]
|
||||
@@ -40,69 +39,34 @@ class CustomResultsModule(MVTModule):
|
||||
self.checked.append(list(self.results))
|
||||
|
||||
|
||||
class BackupCheckerModule(MVTModule):
|
||||
"""An iOS module which implements check_indicators() without declaring check-iocs."""
|
||||
|
||||
slug = "backup_checker"
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
|
||||
checked: list = []
|
||||
|
||||
def run(self) -> None:
|
||||
pass
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
self.checked.append(list(self.results))
|
||||
|
||||
|
||||
class BugReportCheckerModule(MVTModule):
|
||||
"""The same for Android."""
|
||||
|
||||
slug = "bugreport_checker"
|
||||
supported_commands = (("android", "check-bugreport"),)
|
||||
|
||||
checked: list = []
|
||||
|
||||
def run(self) -> None:
|
||||
pass
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
self.checked.append(list(self.results))
|
||||
|
||||
|
||||
class BackupOnlyModule(MVTModule):
|
||||
"""A custom module which does not implement check_indicators()."""
|
||||
"""A custom module which does not declare check-iocs."""
|
||||
|
||||
slug = "backup_only"
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
|
||||
def run(self) -> None:
|
||||
pass
|
||||
def check_indicators(self) -> None:
|
||||
raise AssertionError("must not be re-checked")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"platform, builtin_modules, checker_module",
|
||||
[
|
||||
("ios", IOS_CHECK_IOCS_MODULES, BackupCheckerModule),
|
||||
("android", ANDROID_CHECK_IOCS_MODULES, BugReportCheckerModule),
|
||||
],
|
||||
"platform, builtin_modules",
|
||||
[("ios", IOS_CHECK_IOCS_MODULES), ("android", ANDROID_CHECK_IOCS_MODULES)],
|
||||
)
|
||||
def test_check_iocs_rechecks_the_stored_results_of_custom_modules(
|
||||
platform, builtin_modules, checker_module, tmp_path, caplog
|
||||
platform, builtin_modules, tmp_path, caplog
|
||||
):
|
||||
# check-iocs matches every <slug>.json in the results folder to the module
|
||||
# with that slug, custom modules included, and runs its check_indicators()
|
||||
# again over the stored results.
|
||||
results = [{"domain": "example.org"}]
|
||||
(tmp_path / "custom_results.json").write_text(json.dumps(results))
|
||||
(tmp_path / f"{checker_module.slug}.json").write_text(json.dumps(results))
|
||||
(tmp_path / "backup_only.json").write_text(json.dumps(results))
|
||||
CustomResultsModule.checked.clear()
|
||||
checker_module.checked.clear()
|
||||
|
||||
cmd = CmdCheckIOCS(
|
||||
target_path=str(tmp_path),
|
||||
custom_modules=[CustomResultsModule, checker_module, BackupOnlyModule],
|
||||
custom_modules=[CustomResultsModule, BackupOnlyModule],
|
||||
platform=platform,
|
||||
)
|
||||
cmd.modules = builtin_modules
|
||||
@@ -110,79 +74,29 @@ def test_check_iocs_rechecks_the_stored_results_of_custom_modules(
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
# A module which declares the check-iocs pair is re-checked.
|
||||
assert CustomResultsModule.checked == [results]
|
||||
assert (
|
||||
'Loading results from "custom_results.json" with module CustomResultsModule'
|
||||
in caplog.text
|
||||
)
|
||||
# So is a module which only implements check_indicators().
|
||||
assert checker_module.checked == [results]
|
||||
# A module which does neither is not part of check-iocs.
|
||||
# A module declaring only check-backup is not part of check-iocs.
|
||||
assert "backup_only.json" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"platform, builtin_modules, listed, not_listed",
|
||||
[
|
||||
(
|
||||
"ios",
|
||||
IOS_CHECK_IOCS_MODULES,
|
||||
"BackupCheckerModule",
|
||||
"BugReportCheckerModule",
|
||||
),
|
||||
(
|
||||
"android",
|
||||
ANDROID_CHECK_IOCS_MODULES,
|
||||
"BugReportCheckerModule",
|
||||
"BackupCheckerModule",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_check_iocs_lists_the_custom_modules_it_runs(
|
||||
platform, builtin_modules, listed, not_listed, caplog
|
||||
):
|
||||
def test_check_iocs_lists_custom_modules_declaring_the_command(caplog):
|
||||
cmd = CmdCheckIOCS(
|
||||
custom_modules=[
|
||||
CustomResultsModule,
|
||||
BackupCheckerModule,
|
||||
BugReportCheckerModule,
|
||||
BackupOnlyModule,
|
||||
],
|
||||
platform=platform,
|
||||
custom_modules=[CustomResultsModule, BackupOnlyModule],
|
||||
platform="ios",
|
||||
)
|
||||
cmd.modules = builtin_modules
|
||||
cmd.modules = IOS_CHECK_IOCS_MODULES
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.list_modules()
|
||||
|
||||
assert "CustomResultsModule" in caplog.text
|
||||
# The module which implements check_indicators() for this platform is listed.
|
||||
assert listed in caplog.text
|
||||
# The one for the other platform is not, and neither is BackupOnlyModule.
|
||||
assert not_listed not in caplog.text
|
||||
assert "BackupOnlyModule" not in caplog.text
|
||||
|
||||
|
||||
class ReplacementManifest(Manifest):
|
||||
"""A replacement for a built-in module which does not declare check-iocs."""
|
||||
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = Manifest
|
||||
|
||||
|
||||
def test_check_iocs_uses_a_replacement_of_a_built_in_module():
|
||||
# A replacement which subclasses a built-in module inherits its
|
||||
# check_indicators(). check-iocs then runs it in place of that module.
|
||||
cmd = CmdCheckIOCS(custom_modules=[ReplacementManifest], platform="ios")
|
||||
cmd.modules = IOS_CHECK_IOCS_MODULES
|
||||
|
||||
available = cmd._available_modules()
|
||||
|
||||
assert ReplacementManifest in available
|
||||
assert Manifest not in available
|
||||
|
||||
|
||||
LOADED_MODULE = '''
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
|
||||
@@ -68,123 +68,6 @@ class CustomDependsOnBuiltin(RecordingModule):
|
||||
dependencies = (FirstModule,)
|
||||
|
||||
|
||||
class ReplacementModule(FirstModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = FirstModule
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
self.results = ["replacement"]
|
||||
|
||||
|
||||
class OtherReplacementModule(FirstModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = FirstModule
|
||||
|
||||
|
||||
class UnrelatedReplacementModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = IndependentModule
|
||||
|
||||
|
||||
class ReplacementOfReplacementModule(ReplacementModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = ReplacementModule
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
self.results = ["replacement of replacement"]
|
||||
|
||||
|
||||
class ReplacesOwnDependencyModule(FirstModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = FirstModule
|
||||
dependencies = (FirstModule,)
|
||||
|
||||
|
||||
class DisabledReplacementModule(FirstModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = FirstModule
|
||||
enabled = False
|
||||
|
||||
|
||||
class MutualReplacementOne(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
|
||||
|
||||
class MutualReplacementTwo(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = MutualReplacementOne
|
||||
|
||||
|
||||
MutualReplacementOne.replaces = MutualReplacementTwo
|
||||
|
||||
|
||||
class CycleOneModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
|
||||
|
||||
class CycleTwoModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = CycleOneModule
|
||||
|
||||
|
||||
class CycleThreeModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = CycleTwoModule
|
||||
|
||||
|
||||
CycleOneModule.replaces = CycleThreeModule
|
||||
|
||||
|
||||
class UnavailableDependencyModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
|
||||
|
||||
class ReplacementMissingDependency(FirstModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = FirstModule
|
||||
dependencies = (UnavailableDependencyModule,)
|
||||
|
||||
|
||||
class SharedSlugModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
slug = "shared_slug"
|
||||
|
||||
|
||||
class OtherSharedSlugModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
slug = "shared_slug"
|
||||
|
||||
|
||||
class ReplacementKeepingTheSlug(FirstModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = FirstModule
|
||||
slug = "first_module"
|
||||
|
||||
|
||||
class SameNameReplacementModule(FirstModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
replaces = FirstModule
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
self.results = ["replacement"]
|
||||
|
||||
|
||||
# Modules replacing a built-in one usually keep its class name, which is the
|
||||
# name `--module` matches on.
|
||||
SameNameReplacementModule.__name__ = "FirstModule"
|
||||
|
||||
|
||||
def logged_substitutions(caplog) -> list[str]:
|
||||
return [
|
||||
record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.INFO and "replaces module" in record.getMessage()
|
||||
]
|
||||
|
||||
|
||||
class RecordingCommand(Command):
|
||||
def init(self):
|
||||
self.initialized = True
|
||||
@@ -420,376 +303,3 @@ class TestCommand:
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["FirstModule", "CustomDependsOnBuiltin"]
|
||||
|
||||
def test_custom_module_replaces_builtin(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule, IndependentModule]
|
||||
cmd.custom_modules = [ReplacementModule]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"]
|
||||
assert cmd.module_replacements == {FirstModule: ReplacementModule}
|
||||
assert (
|
||||
"Module ReplacementModule from" in caplog.text
|
||||
and "replaces module FirstModule" in caplog.text
|
||||
)
|
||||
|
||||
def test_modules_without_replaces_all_run(self):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [CustomIOSBackupModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["FirstModule", "CustomIOSBackupModule"]
|
||||
assert cmd.module_replacements == {}
|
||||
|
||||
def test_unavailable_replaced_module_is_ignored(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [IndependentModule]
|
||||
cmd.custom_modules = [ReplacementModule]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"]
|
||||
assert cmd.module_replacements == {}
|
||||
assert "replaces module FirstModule" not in caplog.text
|
||||
|
||||
def test_dependencies_are_resolved_to_the_replacement(self):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [SecondModule, FirstModule]
|
||||
cmd.custom_modules = [ReplacementModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["ReplacementModule", "SecondModule"]
|
||||
second = next(
|
||||
module for module in cmd.executed if isinstance(module, SecondModule)
|
||||
)
|
||||
assert isinstance(second.dependency_modules[FirstModule], ReplacementModule)
|
||||
assert second.results == ["replacement", "second"]
|
||||
|
||||
def test_replacement_with_unavailable_dependency_skips_its_dependents(
|
||||
self, caplog
|
||||
):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule, SecondModule, IndependentModule]
|
||||
cmd.custom_modules = [ReplacementMissingDependency]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
# The replacement cannot run, and the module it replaced was dropped
|
||||
# from the run by the replacement, so neither of them produces
|
||||
# results and the module depending on the replaced one is skipped.
|
||||
assert RecordingModule.run_order == ["IndependentModule"]
|
||||
assert (
|
||||
"Module ReplacementMissingDependency will be SKIPPED: it depends "
|
||||
"on module UnavailableDependencyModule, which is not available in "
|
||||
"this command." in caplog.text
|
||||
)
|
||||
# The skipped dependent is told which module it actually depends on,
|
||||
# and the module class its author declared.
|
||||
assert (
|
||||
"Module SecondModule will be SKIPPED: it depends on module "
|
||||
"ReplacementMissingDependency (replacing module FirstModule), "
|
||||
"itself skipped for depending on unavailable module "
|
||||
"UnavailableDependencyModule." in caplog.text
|
||||
)
|
||||
|
||||
def test_selected_replacement_with_unavailable_dependency_runs_nothing(
|
||||
self, caplog, tmp_path
|
||||
):
|
||||
cmd = RecordingCommand(module_name="FirstModule", results_path=str(tmp_path))
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule, IndependentModule]
|
||||
cmd.custom_modules = [ReplacementMissingDependency]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == []
|
||||
assert not hasattr(cmd, "initialized")
|
||||
assert (
|
||||
"Module FirstModule was replaced by module "
|
||||
"ReplacementMissingDependency, which is run in its place."
|
||||
in caplog.text
|
||||
)
|
||||
assert "Module ReplacementMissingDependency will be SKIPPED" in caplog.text
|
||||
assert "No modules will be run" in caplog.text
|
||||
# Nothing else was selected, so the warnings must not promise that the
|
||||
# analysis continues right before saying that it does not.
|
||||
assert "The rest of the analysis will still run" not in caplog.text
|
||||
# No module ran, so no results were stored next to the command log.
|
||||
assert [path.name for path in tmp_path.iterdir()] == ["command.log"]
|
||||
|
||||
def test_chained_replacements_are_resolved(self):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [SecondModule, FirstModule]
|
||||
cmd.custom_modules = [ReplacementModule, ReplacementOfReplacementModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == [
|
||||
"ReplacementOfReplacementModule",
|
||||
"SecondModule",
|
||||
]
|
||||
second = next(
|
||||
module for module in cmd.executed if isinstance(module, SecondModule)
|
||||
)
|
||||
assert second.results == ["replacement of replacement", "second"]
|
||||
|
||||
def test_replacement_which_is_not_a_subclass_warns(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [IndependentModule]
|
||||
cmd.custom_modules = [UnrelatedReplacementModule]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["UnrelatedReplacementModule"]
|
||||
assert "is not a subclass of it" in caplog.text
|
||||
|
||||
def test_multiple_modules_replacing_the_same_module_warn(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [ReplacementModule, OtherReplacementModule]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == [
|
||||
"ReplacementModule",
|
||||
"OtherReplacementModule",
|
||||
]
|
||||
assert cmd.module_replacements == {FirstModule: ReplacementModule}
|
||||
assert (
|
||||
"Modules ReplacementModule and OtherReplacementModule both replace "
|
||||
"module FirstModule. Both of them will run, FirstModule will not, "
|
||||
"and modules depending on FirstModule will use the results of "
|
||||
"ReplacementModule." in caplog.text
|
||||
)
|
||||
assert "overwrite each other's results file" in caplog.text
|
||||
|
||||
def test_module_replacing_its_own_dependency_runs(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [ReplacesOwnDependencyModule]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["ReplacesOwnDependencyModule"]
|
||||
assert (
|
||||
"Module ReplacesOwnDependencyModule depends on module FirstModule, "
|
||||
"which it also replaces" in caplog.text
|
||||
)
|
||||
|
||||
def test_literal_self_dependency_is_still_circular(self, caplog):
|
||||
class SelfDependentModule(RecordingModule):
|
||||
pass
|
||||
|
||||
SelfDependentModule.dependencies = (SelfDependentModule,)
|
||||
|
||||
cmd = RecordingCommand()
|
||||
cmd.modules = [SelfDependentModule]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == []
|
||||
assert not hasattr(cmd, "initialized")
|
||||
assert "Circular module dependency detected" in caplog.text
|
||||
|
||||
def test_disabled_replacement_keeps_the_replaced_module(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [DisabledReplacementModule]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["FirstModule"]
|
||||
assert cmd.module_replacements == {}
|
||||
assert logged_substitutions(caplog) == []
|
||||
|
||||
def test_modules_replacing_each_other_all_run(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.custom_modules = [MutualReplacementOne, MutualReplacementTwo]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == [
|
||||
"MutualReplacementOne",
|
||||
"MutualReplacementTwo",
|
||||
]
|
||||
assert cmd.module_replacements == {}
|
||||
assert (
|
||||
"Modules MutualReplacementOne, MutualReplacementTwo replace each "
|
||||
"other in a cycle" in caplog.text
|
||||
)
|
||||
assert logged_substitutions(caplog) == []
|
||||
|
||||
def test_replacement_cycle_of_three_modules_all_run(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.custom_modules = [CycleOneModule, CycleTwoModule, CycleThreeModule]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == [
|
||||
"CycleOneModule",
|
||||
"CycleTwoModule",
|
||||
"CycleThreeModule",
|
||||
]
|
||||
assert cmd.module_replacements == {}
|
||||
assert (
|
||||
"Modules CycleOneModule, CycleThreeModule, CycleTwoModule replace "
|
||||
"each other in a cycle" in caplog.text
|
||||
)
|
||||
assert logged_substitutions(caplog) == []
|
||||
|
||||
def test_selected_replaced_module_name_runs_the_replacement(self, caplog):
|
||||
cmd = RecordingCommand(module_name="FirstModule")
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [ReplacementModule]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == ["ReplacementModule"]
|
||||
assert (
|
||||
"Module FirstModule was replaced by module ReplacementModule"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
def test_unknown_selected_module_warns_and_stops(self, caplog):
|
||||
cmd = RecordingCommand(module_name="NoSuchModule")
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == []
|
||||
assert not hasattr(cmd, "initialized")
|
||||
assert (
|
||||
"No module named NoSuchModule is available for the check-backup "
|
||||
"command" in caplog.text
|
||||
)
|
||||
|
||||
def test_selected_module_name_matches_the_replacement(self):
|
||||
cmd = RecordingCommand(module_name="FirstModule")
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [SameNameReplacementModule]
|
||||
|
||||
cmd.run()
|
||||
|
||||
assert len(cmd.executed) == 1
|
||||
assert isinstance(cmd.executed[0], SameNameReplacementModule)
|
||||
assert cmd.executed[0].results == ["replacement"]
|
||||
|
||||
def test_list_modules_reflects_replacements(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule, IndependentModule]
|
||||
cmd.custom_modules = [ReplacementModule]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
cmd.list_modules()
|
||||
|
||||
listed = [
|
||||
record.getMessage()
|
||||
for record in caplog.records
|
||||
if "Modules from" in record.getMessage()
|
||||
]
|
||||
assert any("ReplacementModule" in message for message in listed)
|
||||
assert not any("FirstModule" in message for message in listed)
|
||||
|
||||
def test_modules_sharing_a_slug_are_reported(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.custom_modules = [SharedSlugModule, OtherSharedSlugModule]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert RecordingModule.run_order == [
|
||||
"SharedSlugModule",
|
||||
"OtherSharedSlugModule",
|
||||
]
|
||||
collisions = [
|
||||
record.getMessage()
|
||||
for record in caplog.records
|
||||
if "both use the slug" in record.getMessage()
|
||||
]
|
||||
assert len(collisions) == 1
|
||||
assert "Modules SharedSlugModule from" in collisions[0]
|
||||
assert "and OtherSharedSlugModule from" in collisions[0]
|
||||
assert "both use the slug shared_slug" in collisions[0]
|
||||
assert "overwrites the results of the other in shared_slug.json" in (
|
||||
collisions[0]
|
||||
)
|
||||
|
||||
def test_replacement_keeping_the_replaced_slug_is_not_reported(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [ReplacementKeepingTheSlug]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
# The replaced module is no longer part of the run, so taking over its
|
||||
# slug is what the replacement is for, not a collision.
|
||||
assert RecordingModule.run_order == ["ReplacementKeepingTheSlug"]
|
||||
assert ReplacementKeepingTheSlug.get_slug() == FirstModule.get_slug()
|
||||
assert "both use the slug" not in caplog.text
|
||||
|
||||
def test_modules_with_distinct_slugs_are_not_reported(self, caplog):
|
||||
cmd = RecordingCommand()
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule, IndependentModule]
|
||||
cmd.custom_modules = [CustomIOSBackupModule]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cmd.run()
|
||||
|
||||
assert "both use the slug" not in caplog.text
|
||||
@@ -12,16 +12,13 @@ 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 + SYSDIAGNOSE_MODULES
|
||||
)
|
||||
assert IOS_CHECK_IOCS_MODULES == IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
assert ANDROID_CHECK_IOCS_MODULES == (
|
||||
ANDROID_BACKUP_MODULES
|
||||
+ BUGREPORT_MODULES
|
||||
|
||||
@@ -1,30 +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 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"
|
||||
@@ -1,191 +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 threading
|
||||
from pathlib import Path
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from mvt.ios.decrypt import DecryptBackup, MVTEncryptedBackup
|
||||
|
||||
|
||||
def _encrypted_file(backup_path, file_id, key, plaintext):
|
||||
padding_length = AES.block_size - (len(plaintext) % AES.block_size)
|
||||
padded = plaintext + bytes([padding_length]) * padding_length
|
||||
encrypted = AES.new(key, AES.MODE_CBC, iv=b"\x00" * AES.block_size).encrypt(
|
||||
padded
|
||||
)
|
||||
source_path = backup_path / file_id[:2] / file_id
|
||||
source_path.parent.mkdir(parents=True)
|
||||
source_path.write_bytes(encrypted)
|
||||
|
||||
|
||||
def test_extract_file_by_id_preserves_bytes_with_wrong_manifest_size(
|
||||
mocker, tmp_path
|
||||
):
|
||||
file_id = "ab" + "1" * 38
|
||||
plaintext = b"complete decrypted content"
|
||||
inner_key = b"k" * 32
|
||||
_encrypted_file(tmp_path, file_id, inner_key, plaintext)
|
||||
|
||||
file_plist = mocker.Mock(
|
||||
encryption_key=b"wrapped-key",
|
||||
protection_class=1,
|
||||
filesize=1,
|
||||
mtime=None,
|
||||
)
|
||||
mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist)
|
||||
|
||||
backup = MVTEncryptedBackup(
|
||||
backup_directory=str(tmp_path), derived_key=b"d" * 32
|
||||
)
|
||||
mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True)
|
||||
backup._keybag = mocker.Mock()
|
||||
backup._keybag.unwrapKeyForClass.return_value = inner_key
|
||||
streaming_decrypt = mocker.spy(backup, "_decrypt_file_to_disk")
|
||||
output_path = tmp_path / "output"
|
||||
|
||||
backup.extract_file_by_id(
|
||||
file_id=file_id,
|
||||
file_bplist=b"plist",
|
||||
output_filename=str(output_path),
|
||||
)
|
||||
|
||||
assert output_path.read_bytes() == plaintext
|
||||
streaming_decrypt.assert_called_once()
|
||||
|
||||
|
||||
def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path):
|
||||
file_id = "cd" + "2" * 38
|
||||
source_path = tmp_path / file_id[:2] / file_id
|
||||
source_path.parent.mkdir(parents=True)
|
||||
source_path.write_bytes(b"plain content")
|
||||
|
||||
file_plist = mocker.Mock(encryption_key=None)
|
||||
mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist)
|
||||
backup = MVTEncryptedBackup(
|
||||
backup_directory=str(tmp_path), derived_key=b"d" * 32
|
||||
)
|
||||
mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True)
|
||||
output_path = tmp_path / "output"
|
||||
|
||||
backup.extract_file_by_id(
|
||||
file_id=file_id,
|
||||
file_bplist=b"plist",
|
||||
output_filename=str(output_path),
|
||||
)
|
||||
|
||||
assert output_path.read_bytes() == b"plain content"
|
||||
|
||||
|
||||
def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_path):
|
||||
backup_path = tmp_path / "backup"
|
||||
destination = tmp_path / "destination"
|
||||
outside = tmp_path / "outside"
|
||||
backup_path.mkdir()
|
||||
destination.mkdir()
|
||||
outside.mkdir()
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
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"),
|
||||
]
|
||||
)
|
||||
cursor_context = mocker.MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
|
||||
backup = mocker.MagicMock()
|
||||
backup.manifest_db_cursor.return_value = cursor_context
|
||||
|
||||
def extract_file_by_id(*, output_filename, **kwargs):
|
||||
Path(output_filename).write_bytes(b"decrypted")
|
||||
|
||||
backup.extract_file_by_id.side_effect = extract_file_by_id
|
||||
decryptor = DecryptBackup(
|
||||
str(backup_path), str(destination), max_workers=1
|
||||
)
|
||||
decryptor._backup = backup
|
||||
|
||||
decryptor.process_backup()
|
||||
|
||||
assert (destination / safe_file_id[:2] / safe_file_id).read_bytes() == b"decrypted"
|
||||
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
|
||||
|
||||
|
||||
def test_process_backup_decrypts_files_concurrently(mocker, tmp_path):
|
||||
backup_path = tmp_path / "backup"
|
||||
destination = tmp_path / "destination"
|
||||
backup_path.mkdir()
|
||||
|
||||
file_ids = ["ab" + "1" * 38, "cd" + "2" * 38]
|
||||
for file_id in file_ids:
|
||||
source_path = backup_path / file_id[:2] / file_id
|
||||
source_path.parent.mkdir()
|
||||
source_path.write_bytes(b"encrypted")
|
||||
|
||||
cursor = mocker.MagicMock()
|
||||
cursor.__iter__.return_value = iter(
|
||||
(file_id, "Domain", file_id, b"plist") for file_id in file_ids
|
||||
)
|
||||
cursor_context = mocker.MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
|
||||
barrier = threading.Barrier(2)
|
||||
backup = mocker.MagicMock()
|
||||
backup.manifest_db_cursor.return_value = cursor_context
|
||||
|
||||
def extract_file_by_id(*, file_id, output_filename, **kwargs):
|
||||
barrier.wait(timeout=5)
|
||||
Path(output_filename).write_bytes(file_id.encode())
|
||||
|
||||
backup.extract_file_by_id.side_effect = extract_file_by_id
|
||||
decryptor = DecryptBackup(str(backup_path), str(destination), max_workers=2)
|
||||
decryptor._backup = backup
|
||||
|
||||
decryptor.process_backup()
|
||||
|
||||
for file_id in file_ids:
|
||||
assert (destination / file_id[:2] / file_id).read_bytes() == file_id.encode()
|
||||
|
||||
|
||||
def test_process_backup_logs_worker_errors(mocker, tmp_path, caplog):
|
||||
backup_path = tmp_path / "backup"
|
||||
destination = tmp_path / "destination"
|
||||
backup_path.mkdir()
|
||||
file_id = "ef" + "3" * 38
|
||||
source_path = backup_path / file_id[:2] / file_id
|
||||
source_path.parent.mkdir()
|
||||
source_path.write_bytes(b"encrypted")
|
||||
|
||||
cursor = mocker.MagicMock()
|
||||
cursor.__iter__.return_value = iter([(file_id, "Domain", "failing-file", b"plist")])
|
||||
cursor_context = mocker.MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
|
||||
backup = mocker.MagicMock()
|
||||
backup.manifest_db_cursor.return_value = cursor_context
|
||||
backup.extract_file_by_id.side_effect = ValueError("broken file")
|
||||
decryptor = DecryptBackup(str(backup_path), str(destination))
|
||||
decryptor._backup = backup
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="mvt.ios.decrypt"):
|
||||
decryptor.process_backup()
|
||||
|
||||
assert "Failed to decrypt file failing-file: broken file" in caplog.text
|
||||
@@ -1,4 +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/
|
||||
@@ -1,161 +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 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"
|
||||
@@ -155,7 +155,7 @@ class TestCheckAndroidqfCommand:
|
||||
result = runner.invoke(check_androidqf, [str(path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Skipping backup modules: Invalid backup format" in caplog.text
|
||||
assert "Skipping backup modules as backup.ab is malformed" in caplog.text
|
||||
assert not any(
|
||||
record.levelname in {"CRITICAL", "FATAL"} for record in caplog.records
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.ios.cli import check_sysdiagnose
|
||||
@@ -52,13 +50,8 @@ def test_check_sysdiagnose_runs_explicitly_scoped_custom_module(tmp_path):
|
||||
assert (output_path / "custom_sysdiagnose_module.json").exists()
|
||||
|
||||
|
||||
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))]
|
||||
)
|
||||
def test_check_sysdiagnose_requires_an_explicitly_scoped_module(tmp_path):
|
||||
result = CliRunner().invoke(check_sysdiagnose, [str(_create_sysdiagnose_folder(tmp_path))])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No forensic sysdiagnose modules have been loaded" in caplog.text
|
||||
assert result.exit_code != 0
|
||||
assert "No custom modules support mvt-ios check-sysdiagnose" in result.output
|
||||
@@ -29,6 +29,7 @@ class TestMvtCommand:
|
||||
assert result.exit_code == 0
|
||||
assert "mvt-ios" in result.output
|
||||
assert "mvt-android" in result.output
|
||||
assert "check-*" in result.output
|
||||
|
||||
def test_version_prints_the_installed_version(self):
|
||||
result = CliRunner().invoke(cli, [*OFFLINE, "version"])
|
||||
|
||||
@@ -1,28 +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 pytest
|
||||
|
||||
from .plugin_fixtures import run_isolated_python
|
||||
|
||||
# Importing a platform CLI must only build its command tree: the console
|
||||
# scripts import it before Click can answer a shell completion request, which
|
||||
# the completion scripts make on every keystroke. Every command imports what
|
||||
# it runs when it is invoked. Each of these costs tens of milliseconds to
|
||||
# import and is the sign that a command implementation is imported too early.
|
||||
HEAVY_MODULES = ("pydantic", "requests", "Crypto", "mvt.common.module")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cli_module", ("mvt.ios.cli", "mvt.android.cli"))
|
||||
def test_importing_a_cli_does_not_import_the_module_machinery(cli_module, tmp_path):
|
||||
result = run_isolated_python(
|
||||
"import sys\n"
|
||||
f"import {cli_module}\n"
|
||||
f"print(','.join(name for name in {HEAVY_MODULES!r} if name in sys.modules))\n",
|
||||
home=tmp_path / "home",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "", f"{cli_module} imported {result.stdout.strip()}"
|
||||
@@ -45,11 +45,6 @@ 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]
|
||||
@@ -61,10 +56,10 @@ def _run_command(path):
|
||||
def test_check_sysdiagnose_from_folder(tmp_path):
|
||||
command = _run_command(_create_sysdiagnose_folder(tmp_path))
|
||||
|
||||
assert _test_module(command).results == [
|
||||
assert command.executed[0].results == [
|
||||
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
|
||||
]
|
||||
assert _test_module(command).ips_files == [
|
||||
assert command.executed[0].ips_files == [
|
||||
{"file_path": str(tmp_path / "sysdiagnose" / "report.ips"), "bug_type": 210}
|
||||
]
|
||||
|
||||
@@ -73,12 +68,14 @@ 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 _test_module(command).results == [
|
||||
assert command.executed[0].results == [
|
||||
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
|
||||
]
|
||||
assert _test_module(command).ips_files == [
|
||||
assert command.executed[0].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,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -624,15 +624,16 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iphone-backup-decrypt"
|
||||
version = "0.9.0"
|
||||
name = "iosbackup"
|
||||
version = "0.9.925"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nskeyedunarchiver" },
|
||||
{ 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/db/b8/4cd52322deceb942b9e18b127d45d112c2f7a3ec7821ab528659d4f04275/iOSbackup-0.9.925.tar.gz", hash = "sha256:33545a9249e5b3faaadf1ee782fe6bdfcdb70fae0defba1acee336a65f93d1ca", size = 25228, upload-time = "2022-10-16T01:02:46.474Z" }
|
||||
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/e0/4e/8da5fdc2df642080c79f128b9412a6f33768a610a58d59fbdb7b4d019364/iOSbackup-0.9.925-py3-none-any.whl", hash = "sha256:348edab2b82499d55c17c4bfe02fc6ae7915a2cc8da2728893ed1e9ae61bcef3", size = 18326, upload-time = "2022-10-16T01:02:44.164Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1021,12 +1022,11 @@ dependencies = [
|
||||
{ name = "betterproto2" },
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "iphone-backup-decrypt" },
|
||||
{ name = "iosbackup" },
|
||||
{ name = "libusb1" },
|
||||
{ name = "nskeyedunarchiver" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyahocorasick" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dateutil" },
|
||||
@@ -1068,12 +1068,11 @@ requires-dist = [
|
||||
{ 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 = "iosbackup", specifier = "==0.9.925" },
|
||||
{ name = "libusb1", specifier = "==3.4.0" },
|
||||
{ name = "nskeyedunarchiver", specifier = "==1.5.2" },
|
||||
{ name = "packaging", specifier = "==26.3" },
|
||||
{ name = "pyahocorasick", specifier = "==2.3.1" },
|
||||
{ name = "pycryptodome", specifier = ">=3.20.0" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "pydantic-settings", specifier = "==2.15.0" },
|
||||
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
|
||||
|
||||
Reference in new issue
Block a user