Compare commits

..
Author SHA1 Message Date
besendorf df540d7c41 Merge pull request #984 from arxhr007/feat/979-reprompt-backup-password
Ask for the backup password again when it is wrong
2026-10-28 09:30:52 +01:00
arxhr007 706e81dff2 feat(android): ask for the backup password again when it is wrong
A wrong password typed at the prompt ended the check with "Invalid backup
password", and check-androidqf skipped the backup modules. An analyst who
was handed several candidate passwords had to start the whole run again
for each one.

Ask for the password again, up to three attempts in total. A password
given with --backup-password or in the environment is not asked for
again, as before, so scripted runs fail the same way.

Fixes #979
2026-10-27 21:43:01 +05:30
github-actions[bot] 3ba15716a2 Add new iOS versions and build numbers (#983) 2026-10-27 08:07:27 +01:00
8068569dce Hide the per-command --verbose aliases from --help (#965)
The group-level -v/--verbose on mvt-ios and mvt-android is the documented
form; the per-command flags stay as hidden aliases for compatibility.


Claude-Session: https://claude.ai/code/session_01FiSwVbasf7HbjC18fALafb

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
2026-10-26 15:03:01 +01:00
CHE3MZandDonncha Ó Cearbhaill 6e9408e30c Add favicon and logo to mkdocs site and add dark/light mode toggle (#966)
* Add favicon and logo to mkdocs site and add dark/light mode toggle

* Use black primary colour and follow the OS light/dark mode

Switch the palette to the automatic light/dark mode setup from the
Material for MkDocs docs, with the same toggle icons the Material site
uses. Drop the blue grey accent, which Material does not support as an
accent colour, and remove trailing whitespace.

---------

Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
2026-10-26 13:20:34 +01:00
9 changed files with 166 additions and 116 deletions

No files matched your search

+9 -88
View File
@@ -14,10 +14,10 @@ env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Build each architecture natively, then publish the combined image manifests.
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ${{ matrix.architecture.runner }}
runs-on: ubuntu-latest
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
@@ -26,15 +26,7 @@ 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: ""
@@ -69,95 +61,24 @@ 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.
# Push by digest so architecture builds do not overwrite shared image tags.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v7
with:
file: ${{ matrix.platform.dockerfile }}
context: .
platforms: ${{ matrix.architecture.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
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]}"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+20
View File
@@ -18,8 +18,28 @@ 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
+12 -4
View File
@@ -160,7 +160,9 @@ def check_adb(ctx):
"zone is read from persist.sys.timezone in the bugreport itself."
),
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context
def check_bugreport(
@@ -235,7 +237,9 @@ 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, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
@@ -310,7 +314,9 @@ 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, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
@click.pass_context
def check_androidqf(
@@ -397,7 +403,9 @@ def check_androidqf(
"time instead of UTC."
),
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.argument("LOGS_PATH", type=click.Path(exists=True))
@click.pass_context
def check_intrusion_logs(
+42 -19
View File
@@ -27,6 +27,8 @@ from .modules.backup import BACKUP_MODULES
log = logging.getLogger(__name__)
MAX_BACKUP_PASSWORD_ATTEMPTS = 3
class InvalidAndroidBackup(Exception):
pass
@@ -72,6 +74,15 @@ 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)
@@ -84,28 +95,40 @@ 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":
password = prompt_or_load_android_backup_password(log, self.module_options)
if not password:
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
if self.sub_command:
raise InvalidAndroidBackup("No backup password provided")
log.critical("No backup password provided.")
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)
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:
+9 -3
View File
@@ -278,7 +278,9 @@ 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, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
@@ -347,7 +349,9 @@ 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, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.argument("DUMP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_fs(
@@ -416,7 +420,9 @@ 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, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--verbose", "-v", is_flag=True, hidden=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,6 +1224,10 @@
"version": "18.7.10",
"build": "22H374"
},
{
"version": "18.7.11",
"build": "22H384"
},
{
"version": "26",
"build": "23A341"
@@ -1283,5 +1287,13 @@
{
"version": "27.0.1",
"build": "24A446"
},
{
"version": "27.2",
"build": "24B91020"
},
{
"version": "27.2",
"build": "24B119"
}
]
+60
View File
@@ -59,6 +59,66 @@ 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_says_it_is_kept_for_compatibility(self):
def test_the_command_option_is_hidden_from_the_help(self):
result = CliRunner().invoke(ios_cli, [*OFFLINE, "check-backup", "--help"])
assert result.exit_code == 0
assert "kept for compatibility" in result.output
assert "--verbose" not in result.output