Compare commits

..
19 changed files with 126 additions and 277 deletions

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.11"
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v10.0.0
with:
+88 -9
View File
@@ -14,10 +14,10 @@ env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
# Build each architecture natively, then publish the combined image manifests.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
runs-on: ${{ matrix.architecture.runner }}
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
@@ -26,7 +26,15 @@ jobs:
id-token: write
#
strategy:
fail-fast: false
matrix:
architecture:
- platform: linux/amd64
runner: ubuntu-24.04
name: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
name: arm64
platform:
- dockerfile: "Dockerfile"
tag-suffix: ""
@@ -61,24 +69,95 @@ jobs:
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
type=sha,suffix=${{ matrix.platform.tag-suffix }}
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
# Register emulators so Buildx can build the ARM64 images on the AMD64 runner.
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
# This step sets up some additional capabilities to generate the provenance and sbom attestations
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
# Push by digest so architecture builds do not overwrite shared image tags.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v7
with:
file: ${{ matrix.platform.dockerfile }}
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: ${{ matrix.architecture.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max
sbom: true
- name: Export image digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
mkdir -p "${RUNNER_TEMP}/digests"
touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}"
- name: Upload image digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform.dockerfile }}-${{ matrix.architecture.name }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
publish-manifests:
needs: build-and-push-image
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
strategy:
matrix:
platform:
- dockerfile: "Dockerfile"
tag-suffix: ""
- dockerfile: "Dockerfile.ios"
tag-suffix: "-ios"
- dockerfile: "Dockerfile.android"
tag-suffix: "-android"
steps:
- name: Download image digests
uses: actions/download-artifact@v4
with:
pattern: digests-${{ matrix.platform.dockerfile }}-*
path: ${{ runner.temp }}/digests
merge-multiple: true
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=latest,enable={{ is_default_branch }},suffix=${{ matrix.platform.tag-suffix }}
type=raw,enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }},value=stable,suffix=${{ matrix.platform.tag-suffix }}
type=raw,enable=${{ github.event_name == 'release' }},value=${{ github.event.release.tag_name }},suffix=${{ matrix.platform.tag-suffix }}
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
type=sha,suffix=${{ matrix.platform.tag-suffix }}
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Publish multi-platform image
working-directory: ${{ runner.temp }}/digests
env:
METADATA_JSON: ${{ steps.meta.outputs.json }}
run: |
mapfile -t tags < <(jq -r '.tags[]' <<< "$METADATA_JSON")
tag_args=()
for tag in "${tags[@]}"; do
tag_args+=(--tag "$tag")
done
images=()
for digest in *; do
images+=("${REGISTRY}/${IMAGE_NAME}@sha256:${digest}")
done
test "${#images[@]}" -eq 2
docker buildx imagetools create "${tag_args[@]}" "${images[@]}"
docker buildx imagetools inspect "${tags[0]}"
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.11"
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v10.0.0
with:
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13', '3.14', '3.15']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15']
env:
# Takes precedence over .python-version, which otherwise makes `uv run`
# rebuild the venv with 3.11 and test every matrix entry on 3.11.
# rebuild the venv with 3.10 and test every matrix entry on 3.10.
UV_PYTHON: ${{ matrix.python-version }}
steps:
+1 -1
View File
@@ -1 +1 @@
3.11
3.10
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
# Installation
Before proceeding, please note that MVT requires Python 3.11 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
## Dependencies on Linux
-20
View File
@@ -18,28 +18,8 @@ markdown_extensions:
use_pygments: true
theme:
name: material
logo: mvt.png
favicon: favicon.png
features:
- tabs
palette:
- media: "(prefers-color-scheme)"
primary: black
toggle:
icon: material/link
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: black
toggle:
icon: material/toggle-switch
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: black
toggle:
icon: material/toggle-switch-off
name: Switch to system preference
plugins:
- search
- mkdocstrings
+1 -1
View File
@@ -36,7 +36,7 @@ dependencies = [
"python-dateutil==2.9.0.post0",
"tzdata==2026.3",
]
requires-python = ">= 3.11"
requires-python = ">= 3.10"
[project.urls]
homepage = "https://docs.mvt.re/en/latest/"
+4 -12
View File
@@ -160,9 +160,7 @@ def check_adb(ctx):
"zone is read from persist.sys.timezone in the bugreport itself."
),
)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context
def check_bugreport(
@@ -237,9 +235,7 @@ def check_bugreport(
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
@@ -314,9 +310,7 @@ def check_backup(
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
@click.pass_context
def check_androidqf(
@@ -403,9 +397,7 @@ def check_androidqf(
"time instead of UTC."
),
)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("LOGS_PATH", type=click.Path(exists=True))
@click.pass_context
def check_intrusion_logs(
+19 -42
View File
@@ -27,8 +27,6 @@ from .modules.backup import BACKUP_MODULES
log = logging.getLogger(__name__)
MAX_BACKUP_PASSWORD_ATTEMPTS = 3
class InvalidAndroidBackup(Exception):
pass
@@ -74,15 +72,6 @@ class CmdAndroidCheckBackup(Command):
self.__tar: Optional[tarfile.TarFile] = None
self.__files: List[str] = []
def _get_backup_password(self) -> str:
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)
return password
def from_ab(self, ab_file_bytes: bytes) -> None:
self.__type = "ab"
header = parse_ab_header(ab_file_bytes)
@@ -95,40 +84,28 @@ class CmdAndroidCheckBackup(Command):
sys.exit(1)
password = None
# A password typed at the prompt is asked for again if it is wrong.
# One given on the command line or in the environment is not.
max_attempts = 1
if header["encryption"] != "none":
if not self.module_options.get("backup_password", None):
max_attempts = MAX_BACKUP_PASSWORD_ATTEMPTS
password = self._get_backup_password()
attempt = 1
while True:
try:
tardata = parse_backup_file(ab_file_bytes, password=password)
break
except InvalidBackupPassword:
if attempt < max_attempts:
attempt += 1
log.error(
"Invalid backup password, try again (attempt %d of %d)",
attempt,
max_attempts,
)
password = self._get_backup_password()
continue
password = prompt_or_load_android_backup_password(log, self.module_options)
if not password:
if self.sub_command:
raise InvalidAndroidBackup("Invalid backup password")
log.critical("Invalid backup password")
sys.exit(1)
except AndroidBackupParsingError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
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:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1)
dbytes = io.BytesIO(tardata)
try:
+1 -1
View File
@@ -59,7 +59,7 @@ class SMS(BackupModule):
def collect_url_results(self) -> None:
for message in self.results:
for url in message.get("links") or []:
for url in message.get("links", []):
self.add_url_result(url, message.get("isodate"), "sms")
def run(self) -> None:
+1 -1
View File
@@ -6,7 +6,7 @@
"""Password prompts with masked keyboard feedback.
This is based on the ``echo_char`` support added to :mod:`getpass` in Python
3.14. MVT supports Python 3.11 and later, so it cannot use that API directly.
3.14. MVT supports Python 3.10 and later, so it cannot use that API directly.
"""
import contextlib
+3 -9
View File
@@ -278,9 +278,7 @@ def extract_key(password, key_file, backup_path):
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
@@ -349,9 +347,7 @@ def check_backup(
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("DUMP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_fs(
@@ -420,9 +416,7 @@ def check_fs(
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
@click.pass_context
def check_sysdiagnose(
-12
View File
@@ -1224,10 +1224,6 @@
"version": "18.7.10",
"build": "22H374"
},
{
"version": "18.7.11",
"build": "22H384"
},
{
"version": "26",
"build": "23A341"
@@ -1287,13 +1283,5 @@
{
"version": "27.0.1",
"build": "24A446"
},
{
"version": "27.2",
"build": "24B91020"
},
{
"version": "27.2",
"build": "24B119"
}
]
-101
View File
@@ -4,118 +4,17 @@
# https://license.mvt.re/1.1/
import io
import json
import logging
import os
import tarfile
import zlib
import pytest
from mvt.android.modules.backup.sms import SMS
from mvt.android.parsers.backup import parse_backup_file
from mvt.common.config import settings
from mvt.common.module import run_module
from ..utils import get_android_backup_folder
class TestBackupModule:
@pytest.mark.parametrize("link_fields", [{}, {"links": None}, {"links": []}])
def test_collect_urls_with_empty_links(self, link_fields):
mod = SMS()
mod.results = [
{"links": ["https://example.org/first"], "isodate": "2026-11-04 12:00:00"},
{"body": "", **link_fields},
{"links": ["https://example.org/last"], "isodate": "2026-11-04 13:00:00"},
]
mod.collect_url_results()
assert mod.url_results == [
{
"url": "https://example.org/first",
"expanded_url": None,
"timestamp": "2026-11-04 12:00:00",
"source": "sms",
},
{
"url": "https://example.org/last",
"expanded_url": None,
"timestamp": "2026-11-04 13:00:00",
"source": "sms",
},
]
@pytest.mark.parametrize("message_type", ["sms", "mms"])
@pytest.mark.parametrize("with_indicators", [False, True])
def test_blank_message_backup(
self,
tmp_path,
caplog,
monkeypatch,
indicators_factory,
message_type,
with_indicators,
):
monkeypatch.setattr(settings, "NETWORK_ACCESS_ALLOWED", False)
backup_path = tmp_path / "backup"
relative_path = (
f"apps/com.android.providers.telephony/d_f/000000_{message_type}_backup"
)
message_file = backup_path / relative_path
message_file.parent.mkdir(parents=True)
body_key = "body" if message_type == "sms" else "mms_body"
messages = [
{
body_key: body,
"date": str(1735689600000 + index * 1000),
"date_sent": "0",
}
for index, body in enumerate(
[
"",
"https://suspicious.example.org/first",
"ordinary message",
" \t ",
"",
"https://suspicious.example.org/last",
]
)
]
message_file.write_bytes(zlib.compress(json.dumps(messages).encode()))
results_path = tmp_path / "results"
results_path.mkdir()
mod = SMS(target_path=str(backup_path), results_path=str(results_path))
mod.from_dir(str(backup_path), [relative_path])
if with_indicators:
mod.indicators = indicators_factory(domains=["suspicious.example.org"])
with caplog.at_level(logging.ERROR):
run_module(mod)
assert not [
record for record in caplog.records if record.levelno == logging.ERROR
]
assert len(mod.results) == len(messages)
assert mod.results[0]["links"] is None
assert mod.url_results == [
{
"url": f"https://suspicious.example.org/{suffix}",
"expanded_url": None,
"timestamp": mod.results[index]["isodate"],
"source": "sms",
}
for index, suffix in [(1, "first"), (5, "last")]
]
assert len(mod.alertstore.alerts) == (2 if with_indicators else 0)
if with_indicators:
assert [alert.event for alert in mod.alertstore.alerts] == [
mod.results[1],
mod.results[5],
]
assert json.loads((results_path / "sms.json").read_text()) == mod.results
def test_module_folder(self):
backup_path = get_android_backup_folder()
mod = SMS(target_path=backup_path)
-60
View File
@@ -59,66 +59,6 @@ class TestCheckAndroidBackupCommand:
assert result.exit_code == 1
assert "Invalid backup password" in caplog.text
def test_check_encrypted_backup_prompt_retry(self, mocker):
"""A wrong password at the prompt is asked for again"""
prompt_mock = mocker.patch(
"mvt.android.modules.backup.helpers.prompt_password",
side_effect=["wrong_password", TEST_BACKUP_PASSWORD],
)
runner = CliRunner()
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
result = runner.invoke(check_backup, [path])
assert prompt_mock.call_count == 2
assert result.exit_code == 0
def test_check_encrypted_backup_prompt_gives_up(self, mocker, caplog):
"""The prompt gives up after three wrong passwords"""
prompt_mock = mocker.patch(
"mvt.android.modules.backup.helpers.prompt_password",
return_value="wrong_password",
)
runner = CliRunner()
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
with caplog.at_level(logging.CRITICAL):
result = runner.invoke(check_backup, [path])
assert prompt_mock.call_count == 3
assert result.exit_code == 1
assert "Invalid backup password" in caplog.text
def test_check_encrypted_backup_prompt_retry_empty(self, mocker, caplog):
"""An empty answer on a retry ends the check"""
prompt_mock = mocker.patch(
"mvt.android.modules.backup.helpers.prompt_password",
side_effect=["wrong_password", ""],
)
runner = CliRunner()
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
with caplog.at_level(logging.CRITICAL):
result = runner.invoke(check_backup, [path])
assert prompt_mock.call_count == 2
assert result.exit_code == 1
assert "No backup password provided" in caplog.text
def test_check_encrypted_backup_cli_invalid_no_prompt(self, mocker):
"""A password given on the command line is not asked for again"""
prompt_mock = mocker.patch(
"mvt.android.modules.backup.helpers.prompt_password",
return_value=TEST_BACKUP_PASSWORD,
)
runner = CliRunner()
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
result = runner.invoke(
check_backup, ["--backup-password", "invalid_password", path]
)
assert prompt_mock.call_count == 0
assert result.exit_code == 1
def test_check_encrypted_backup_env(self, mocker):
"""Provide password as environment variable"""
prompt_mock = mocker.patch(
+2 -2
View File
@@ -100,8 +100,8 @@ class TestVerboseOnTheCheckCommands:
assert result.exit_code == 0
assert _console_level() == logging.DEBUG
def test_the_command_option_is_hidden_from_the_help(self):
def test_the_command_option_says_it_is_kept_for_compatibility(self):
result = CliRunner().invoke(ios_cli, [*OFFLINE, "check-backup", "--help"])
assert result.exit_code == 0
assert "--verbose" not in result.output
assert "kept for compatibility" in result.output
Generated
+1 -1
View File
@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.11"
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",